Skip to content

Latest commit

 

History

History
473 lines (404 loc) · 34.7 KB

File metadata and controls

473 lines (404 loc) · 34.7 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

The spec is the source of truth

docs/rule-engine-spec.md (~1900 lines) specifies this engine in full. The code implements it section by section, and comments cite section numbers (§3.4.1, section 6.5) as their justification. Before changing matching, agenda, update or RHS semantics, read the section that governs it — the answer to "why is it written this way" is almost always there, along with the alternatives that were rejected. If the code and the spec disagree, one of them is a defect; decide which and say so, do not silently pick.

README.md is the introduction, not a build diary and not an adoption committee's briefing: what the engine is, what a rule looks like, how to run one, and where the other documents are. It was rewritten twice — once out of phase-by-phase status narration, and once out of a caveat-first ordering that disqualified the reader in five bullets before showing them the engine working. Reasons not to adopt do not belong at the top. A first-time reader gets the capability first; the two facts that end the conversation regardless (Java 25 at runtime, Jackson 3 on the classpath) sit beside the dependency snippet where they are actionable. README keeps one short two-way section, Is this engine for you?, placed after the reader has seen a rule run — it names poor fits as well as good ones and links onward; the full account of fit lives in docs/choosing-this-engine.md. The one caveat README keeps inline is the eviction hazard, because it traps rule authors rather than evaluators.

docs/choosing-this-engine.md is where "should I use this" is answered in both directions: the two hard requirements, the workload shapes it suits, the ones it does not, §9.1's not-built table, the comparisons against Drools and hand-written conditionals, project maturity, and getting out. It holds the not-built table under that exact heading because CHANGELOG.md links the anchor. Its comparisons are capability claims only — this project benchmarks nothing but itself, so no performance claim about another engine goes in it — and the Drools half is the spec's own §0 framing rather than anything invented later. It prints no complete rule files, which is what keeps it out of DocExamplesTest and out of the task-input list.

§9 of the spec holds the roadmap and each phase's exit criteria, and that is where phase talk belongs; §11.2's differential propagation is the one deliverable that is measured and deliberately not built.

docs/embedding.md is the host-side manual — sessions, SessionOptions, limits, concurrency, operations, diagnosing production, getting out. It exists because the DSL had two documents and the Java embedding API had none, so README was substituting for it. Host-API facts belong there, not in README. CHANGELOG.md gets an entry per release (RELEASING.md has the step) and SECURITY.md carries the reporting address and the honest note that there is no automated advisory gate.

rule-engine-example/README.md is the worked application, and README points at it first. Its rule file, its feed and its four demos are compiled and executed by CI.

docs/dsl-reference.md and docs/dsl-guide.md document the rule-file DSL. Every rule file printed in either — and in README — is a compiled fixture; if a doc and the engine disagree, the doc is wrong. Which test compiles it depends on whether it needs §6.4's escape hatch: DocExamplesTest filters out anything containing condition: or $expr, because -testkit does not depend on -cel, and CelDocExamplesTest picks those up — for docs/ and for README, whose $expr example nothing else would compile.

Build and test

Requires a JDK 25 toolchain; Gradle resolves one via the foojay plugin if it is not installed.

./gradlew build          # compile + test + strictTest (check depends on strictTest)
./gradlew test           # the suite
./gradlew strictTest     # the same suite with -Drules.strict=true (§7.5)
./gradlew javadoc        # doclint:all with -Werror; load-bearing contracts live only in Javadoc
./gradlew testCodeCoverageReport   # aggregated across modules; per-module coverage is misleading
./gradlew :rule-engine-testkit:jmh # benchmarks; see docs/benchmarks.md

# one test class / one method
./gradlew :rule-engine-testkit:test --tests '*JoinAndAliasTest'
./gradlew :rule-engine-testkit:test --tests '*JoinAndAliasTest.someMethod'
./gradlew :rule-engine-testkit:strictTest --tests '*JoinAndAliasTest'
./gradlew :rule-engine-dsl:test --tests '*OperatorMapTest'

CI (.github/workflows/gradle.yml) runs ./gradlew build javadoc. Two things fail the build that usually do not: -Xlint:all -Werror on every module, and Javadoc warnings. Every public element needs a complete Javadoc with @param/@return, including on records and builders.

Most tests live in rule-engine-testkit/src/test even when they exercise -core, because they are end-to-end. That is why coverage is aggregated at the root — a per-module report attributed 26% to -core when the real figure was three times that (currently 93.5% line, 94.6% instruction).

Before running git commit, always delegate to the senior-reviewer subagent to review the staged diff. Do not commit if it flags any Blockers until they're addressed.

Modules

Module Contents
rule-engine-core Fact model, working memory, all three matchers, agenda, refraction, RHS execution, sessions
rule-engine-compiler RuleDefinitionCompiledRuleSet: validation, accessor/pattern compilation, TestedPaths, network build, version hash, CompilerReport
rule-engine-dsl JSON/YAML rule files → RuleDefinition; the rules.v1 schema; located diagnostics
rule-engine-schema The optional FactSchemas of §2.3, backed by networknt JSON Schema
rule-engine-cel The optional §6.4 expression escape hatch, backed by dev.cel
rule-engine-observability TracingListener, JfrListener, MatchExplainer
rule-engine-testkit Rules builder, Engine/FiringSequence, MatcherEquivalence, ShuffleHarness, JMH benchmarks — main source set, not test: consumers use these
rule-engine-example The worked application: rules/orders.yaml, a ten-event feed, and four demos (PerOrderDemo, BatchDemo, StreamingDemo, DiagnosticsDemo). Not a library — see below

Dependencies are declared in gradle/libs.versions.toml. -core's runtime deps are exactly jackson (the fact model is JSON-native) and re2j (rule-authored matches must not backtrack catastrophically); both are api because they appear in public signatures. Adding a -core dependency is a design decision, not a convenience.

rule-engine-example is teaching material held to the same bar as the engine. It applies the library conventions (so -Xlint:all -Werror, doclint and strictTest all apply) plus the application plugin, because an example nobody can start is a listing. Three things it must keep doing: use only the exported API — ApiSurfaceTest grants it no internal package, and the day it needs one the contract is missing something; keep -testkit at test scope, because Facts.json is a fixture and main source should show a real ingestion path; and stay out of jacocoAggregation in the root build, because how much of a demo the demo runs is not a number anyone should act on. Its README.md is declared as a task input in its own build file — ReadmeExamplesTest reads it, and an undeclared input means the guard silently stops guarding.

Adding any module means adding it to ApiSurfaceTest.INTERNAL_ACCESS; that table is asserted to name every include(...) in settings.gradle.kts, because a module missing from it went unchecked while the suite stayed green.

The build publishes to Maven Central, and that changes what a mistake costs. Seven modules ship under com.codeheadsystems; rule-engine-example does not, because an artifact is a promise to keep something compiling and nobody should depend on the example. A module publishes iff it applies buildlogic.publish-conventions, and PublishedModulesTest asserts that set against the build files — the same shape as ApiSurfaceTest's module table, for the same reason: a module missing from a list nobody checks goes unnoticed while the suite stays green. Every POM declares Apache-2.0, so LICENSE is load-bearing rather than decorative. The version comes from a Git tag — settings.gradle.kts reads git describe --exact-match and substitutes it, and gradle.properties holds the next -SNAPSHOT — so nothing in the tree ever states a released version. RELEASING.md is the whole procedure. Two consequences worth holding in mind while editing -core: anything public in an exported package is a compatibility surface until the next major, and a Jackson major upgrade is a major version here because JsonNode is in ~60 public signatures.

-dsl adds jackson-dataformat-yaml and networknt json-schema-validator; -schema adds networknt too. Both are implementation and neither reaches -core, which is the point of the SPI split below.

This engine is on Jackson 3 (tools.jackson.*), not Jackson 2. networknt tracks it on the 3.x line; the projects maintain 2.x and 3.x in parallel, so the pin follows our tree model rather than their support window. The move was made while the project was still unreleased and deliberately so: JsonNode appears in ~60 public signatures and -core declares jackson api, so after a first publish this would break every consumer at once with no gradual path.

The trap when touching Jackson code here. Jackson 3 made the typed accessors strict. stringValue(), intValue(), longValue(), doubleValue(), booleanValue() and decimalValue() now throw on a type mismatch where Jackson 2's textValue()/intValue() returned null/0/false — and so does the coercing asX() family (asString(), asBoolean(), asInt(), asDouble(), asDecimal()), which is easy to miss because coercion sounds total. Comparisons calls asBoolean() on the matching hot path; it is safe only because the compiler rejects a non-boolean HAS_FIELD/IS_NULL literal and forbids both as join operators. Most of those kept their names, so the compiler says nothing — a missing type guard is a runtime throw on the matching path, not a wrong answer. Every call site in main source is guarded (isString(), isNumber(), or a compile-time rejection), and it must stay that way. Where Jackson 2's null-returning behaviour is what you want, the one-argument form (stringValue(null)) is the equivalent. asString() throws on objects and arrays too, where asText() returned "" — that one bit RuleFileReader's apiVersion diagnostic, on untrusted input; see RuleFilesTest.ApiVersionShape.

Container EQ no longer delegates to JsonNode.equals — see the §2.6.1 amendment. Jackson 3's DecimalNode equality is scale-sensitive where Jackson 2's was not, which made 100.00 and 100.0 unequal inside a container while Canonical kept them equal as scalars. Comparisons walks containers itself now, comparing numbers through Canonical at every depth.

Rule-set version hashes did not move. RuleCompiler.version() hashes a canonical string built from rule.when()/rule.then(), whose records render their JsonNodes via toString() — and Jackson 3's toString() is byte-identical to Jackson 2's for every node type this engine produces (objects, arrays, all scalars, and BigDecimal trailing zeros). Verified directly against both jars before the migration was committed, because §5.6's hot reload, refraction and RuleSetFingerprint all key on that identity.

Architecture

The two-tier split

CompiledRuleSet is immutable, thread-safe, and shared by everything. RuleSession is single-writer, cheap to allocate, and never shared across threads — one virtual thread per session is the concurrency primitive (§5.2). halt() is the only method legal to call from another thread.

The shared graph holds structure and plans; everything it stores lives in the session's SessionMemories, a NodeMemory[] indexed by node id. Nothing may be added to CompiledRuleSet, Network, or a node that mutates after compile.

Three matchers, held to agreement

All three are subclasses of RecomputingAgenda, which owns everything that decides which activation fires — dirty tracking, the recomputation loop, refraction at selection, negation (§1's NOT_EXISTS), the §6.4 condition post-filter, the conflict-resolution comparator, strict-mode checks. Subclasses supply only matchesOf(rule, ...): how matches are found. Keeping the divergence-capable code in one place is what makes the three matchers agree — and note what that costs a test: anything answered in the base agrees by construction, so MatcherEquivalence can never fail for that reason. Assert what fired, not only that the three concur.

  • NaiveAgenda (naive/, Phase 0) — no network, no indexes, O(rules × facts^arity). It is the correctness oracle and is deliberately still shipped. Selected with SessionOptions.matching(MatchingStrategy.NAIVE). Never in production.
  • ReteAgenda (rete/, Phase 3) — joins materialised as facts arrive, in BetaMemory, instead of recomputed per fire. Selected with MatchingStrategy.RETE, for long-lived streaming sessions. Shares the join walk with NetworkAgenda via JoinEnumerator — a pinned position makes the incremental result a subset of the full one by construction, which is what §9's "TREAT and Rete produce identical firing sequences" rests on. Its conflict set is pushed and pulled rather than rebuilt (§4.3): a match enters when derived, leaves when it fires, and a fire cycle ranks what is waiting rather than everything held. That is what makes it a better curve and not merely a constant — the fire cycle stopped growing with the working set — and it is why pendingByRule must never be allowed to hold a match that has fired. A §6.4 condition is the exception: rejected matches are never fired, so they are never pulled, and the set drifts toward the join memory. See docs/benchmarks.md.
  • NetworkAgenda (network/, Phases 1–2) — the default. EntryNode (per fact type) → shared AlphaNodes (one per distinct constraint) → PatternNode + its PatternMemory → indexed joins ordered per fire cycle by JoinPlan (smallest memory first, connected before disconnected).

Any change to matching must keep all three identical. MatcherEquivalence.assertEquivalent compares whole firing sequences — which rule, on which facts, in what order, with what effects and events. Use it for new matching behaviour; ShuffleHarness covers §7.3's determinism contract.

Invariants that produce silently-wrong output when broken

  • Tuples bind FactHandles, never Fact objects. Payloads are dereferenced from working memory at read time, so nothing downstream can serve a stale one. Audit this whenever a node type is added.
  • Insert evaluates tests; retract never does. A retract removes by handle identity and computes its index-removal keys from the payload the fact had when asserted. Re-deriving membership from current data leaves entries behind and produces phantom matches forever.
  • The index is a pure optimisation. Probe results are intersected with actual pattern membership and every join is re-evaluated, so a too-wide index is slow. A too-narrow one is a lost firing — which is why a probe that cannot prove itself safe must decline (no index usable) rather than return zero candidates.
  • Determinism. Same rule set, same facts, same insertion order → same firing sequence, on every host and run. The threat that actually bites is hash iteration order reaching the agenda; prefer LinkedHashMap/LinkedHashSet/sorted structures on any path to the agenda. This is also what makes exportFacts()/SessionDrain.replay order by handle id rather than however a map iterates.
  • Nothing in a CompiledRuleSet mutates after compile (§5.5, invariant 1). Every scaling claim rests on it, and it is not free: FieldConstraint, RangeConstraint and Literal deep-copy their JsonNode on the way in but hand back the live node, so a caller reaching a literal through CompiledRule.source() can mutate a node every session reads. Copying on the way out is not available — the matching path calls literal() per fact per test. RuleSetFingerprint hashes every mutable value at compile time and newSession(strict) re-verifies, so violators fail in test; outside strict mode it stays a caller-facing contract. See ImmutabilityTest.

The DSL front end

Rule-file text → POJO tree → RuleDefinition → the existing RuleCompiler. The DSL builds no network and re-implements no semantic validation. Three gates, and duplicating one in another is how they drift apart:

  1. rules.v1.json (in -dsl resources) — structure only: required keys, value types, unknown keys, which keys each action verb accepts. Runs first and hard-stops.
  2. OperatorMaps / Actions / References / Quantifiers — what a schema cannot say: $ref vs the $$ escape vs a rejected $-key, a between with no bound, a malformed alias.field, a quantifier spelling and the §1 answer for the two it does not implement.
  3. RuleCompiler, unchanged — meaning: forward refs, unknown aliases, duplicate ids, regexes, function names.

DslError.shieldedBySchema() marks the codes gate 1 catches first. Those checks stay in gate 2 anyway — "the gate ahead of me guarantees this" is how a loosened schema becomes a silently dropped constraint — and DslDiagnosticsTest asserts both that they are unreachable end-to-end and that their components still raise them.

RuleFiles re-decorates RuleCompilationException's diagnostics with file/line/column by matching the prefixes the compiler writes ("<ruleId>: <alias>.<field>: …"). That coupling is deliberate — it keeps the cost on the side that wants the feature — and DslDiagnosticsTest is what notices if the compiler rewords itself.

DslEquivalence (in -testkit) is the DSL's oracle test, mirroring MatcherEquivalence: a rule file and the same rule built with Rules must produce an identical rule-set version hash and an identical firing sequence. The hash half is the strong one — it caught both defects this module surfaced (RangeConstraint's un-normalised inclusivity, and the testkit builder emitting FieldConstraint(GT) where §6.2.1 says RangeConstraint).

Optional modules plug in through a -core SPI

-schema and -cel follow the pattern TestedPaths, HostFunction and EventSink already use: -core declares an interface, an optional module implements it, and it is wired in through CompilerOptions and frozen into the CompiledRuleSet. -core gains no dependency either time.

FactSchemas (§2.3) is a documented deviation from the spec's sketch, which returns networknt's JsonSchema and would put that library on every consumer's classpath. It answers in this engine's own vocabulary instead, and answers UNKNOWN/empty wherever schema introspection stops being simple ($ref, allOf, oneOf) — an unmade check costs what you had before registering a schema, where a guessed one would reject a correct rule. Validation has no such limit.

A condition: makes the paths it reads tested paths (§3.4.1), and the cost is behavioural. RuleCompiler.compileCondition records the payload root for every fact type an alias the condition references binds — conservative on purpose, because extracting exact read paths from the CEL AST would make the compiler responsible for being a superset of what an arbitrary expression reads, and under-declaring loses a firing silently (§11.2's rejected dependsOn() trap).

Because the root is tested, any update to a fact the rule binds un-refracts it, including a field no rule reads: the rule re-fires, and a rule whose RHS mutates its own facts goes from firing once to hitting maxCycles. noLoop restores it. That is a real semantics consequence, not a slow path, and it is why the §6.4 amendment states it beside the argument rather than as a performance note.

Until Phase 3 nothing was recorded at all, so an update that made a condition newly true fired nothing. No differential test could catch that: the update gate is upstream of the matcher, so every matcher was identically wrong and MatcherEquivalence only proved they agreed. Worth remembering whenever equivalence testing is the argument for correctness here.

CEL (§6.4) evaluates in two places, and one of them is a structural decision. A pattern condition: is a post-filter applied in RecomputingAgenda, the shared base — not in either matcher. Everything that decides which activation fires already lives there so the three matchers cannot diverge, and an expression is exactly what would drift if written twice; this way MatcherEquivalence holds by construction. An $expr value resolves in RhsExecutor.resolve, once per firing rather than once per candidate.

Note where reality departs from §6.4: it says dev.cel "ships a static cost estimator and a runtime cost limit — set both". As of 0.14.0 it ships neither. CelExpressions uses its own structural estimate at compile time and dev.cel's comprehensionMaxIterations/parse limits at run time, and says so. Determinism is a property of that environment: CEL's standard set has no clock, and CelExpressions binds only the tuple's aliases — adding a binding there is a §7.3 decision.

Update, refraction, RHS

update is retract + reassert on the same handle, gated on a tested-path diff (§3.4.1): if no path any rule tests changed, it propagates nothing, and DefaultWorkingMemory exposes counters that tests assert on. Refraction is cleared for exactly the rules testing a changed path.

RHS execution is stage-everything-then-commit (§4.6), five verbs only (setField, insert, retract, emit, callFunction). Atomicity is per-phase: a staging failure applies nothing; a commit failure leaves what already landed, and FireRecord is how that partial state is discoverable. Under the default RETHROW policy the record only reaches a registered listener.

Strict mode

-Drules.strict=true (or SessionOptions.strict(true)) turns on checks too expensive for production that fail deterministically in test: payload copies on the way out, rejection of an update that aliases the stored payload, an assertion that conflict resolution is a total order consistent with equality, and a re-check of the compiled rule set's literal fingerprint at session creation. §7.5 requires the full suite under it in CI and forbids it in production.

Concurrency and hot reload (-core, concurrent/)

In -core rather than its own module because §8 says so directly: a few hundred lines with no dependencies beyond the JDK, where a module boundary buys nothing and makes "how do I run this concurrently" an extra artifact to discover.

  • RuleBatches — one virtual thread and one session per batch. Returns a BatchOutcome per batch carrying either a result or a failure, because §5.2 refuses to decide for you what a partial batch result means. Sessions are created inside the task and closed in try-with-resources; one escaping to the caller would break the single-writer model.
  • RuleSetHolder — §5.6's hot reload. One volatile field, no locks. Two contracts worth knowing before changing it: publish takes a compiled rule set so a bad rule file cannot take the engine out of service, and a swap affects new sessions only.
  • SessionEvictor (in runtime/, with the policy SPI in evict/) — §4.4's fact eviction, which bounds every structure a long-lived session grows because they are all keyed on handles. Two things to know before touching it: an eviction is an ordinary retract and must stay one — reaching into the memories by hand makes it a fifth place they are removed from — and it may only run at quiescence. The policy is consulted after a caller's insert and at the top of a fire cycle, never between §4.6's staging and commit, where it could retract a fact the firing activation binds. A policy must also be a pure function of what it is shown; strict mode calls it twice and compares, because a clock or a HashMap in there is a §7.3 violation that only shows on another host. Three policies ship. leastRecentlyUsed and perType bound the arrival count; window bounds time, reading it from a field on the facts and taking its far edge from the newest value that type currently holds. That watermark is what keeps it out of §4.4's refused TTL — it is derived from the input, so the same stream evicts the same facts — and it is the retention half of windowing, whose matching half is a temporal join's within. The two are separate decisions and nothing checks that they agree: retain less than the widest window written against a type and the rule silently loses matches. See §4.4's second amendment.
  • SessionDrain — drain-and-restart for a session already running when the rules changed. Two things it must keep doing: replay in handle-id order (§7.3's guarantee is stated in terms of insertion order) and skip Origin.DERIVED facts (the new session re-derives them; replaying would double-count). Refraction state is deliberately not carried over — the handles are new.

Semantics that surprise people (all deliberate)

  • Absent ≠ null. { eq: null } matches an explicit JSON null, never an absent field; use hasField: false for absence.
  • ne is true for an absent field, because ne is defined as !eq. Pair with hasField: true.
  • in is eq against each element (§2.6.1).
  • Distinct aliases in one rule bind distinct facts. The compiler inserts an implicit inequality between same-type aliases — and JoinPlan symmetrises it, because either end may be bound first.
  • Collections are flattened at ingestion, not matched inside a fact. JSON Pointer has no wildcard.
  • Temporal after/before read time from the facts; the engine owns no clock (§2.5's third amendment). That is what keeps §7.3: a wall clock would make the firing sequence depend on when it ran. The within bound is required (an unbounded ordering is gt against the same $ref) and is in the time field's own units, because only the author knows them. Operator.reversed() declines for both, so they are never index-eligible — a reversal would leave the bound behind and widen the rule. Sliding windows and "nothing happened for 24h" are NOT built and cannot be without a clock or a caller-driven session time.
  • ACCUMULATE binds a value that is folded at read time, never stored (§2.5's second amendment). That is what keeps "tuples bind handles, never values" true: a stored aggregate goes stale the instant any fact in its scope moves, and the streaming matcher holds tuples across cycles. Every constraint selects the scope, unlike FOR_ALL. The answer is readable from an action, a §6.4 expression and its own having; nothing may join to it. An absent field is skipped rather than folded as zero, and an empty scope is 0 for count/sum and absent for min/max/average.
  • A quantified pattern (NOT_EXISTS, FOR_ALL) binds nothing, and nothing may name its alias — not a $ref, not an action, not an insertFact's as, not a §6.4 expression. All four are compile errors that name which quantifier it is, because an alias the author can see, reported as one the rule does not have, sends them hunting a typo that is not there.
  • A FOR_ALL's joins choose the scope; its own constraints are the requirement (§2.5's amendment). forAll li: LineItem (orderId = o.id, inStock) is "every line item of this order is in stock". The literal reading — every fact of the type satisfies everything written — makes any joined FOR_ALL false as soon as a second order exists, so the rule can never fire. PatternTests is where the two halves are asked separately; Negations conjoins them, Universals does not.
  • A FOR_ALL is vacuously true over an empty scope, which is classical and is the trap. Pair it with a positive pattern of the same type to mean "there are some, and all of them". Over an evicted type this is the sharpest hazard in the engine: eviction only removes counterexamples, so a cap does not weaken the requirement but deletes it.
  • Truth maintenance is opt-in per insert (§4.4's amendment). insertFact with logical: true makes the fact a conclusion held up by the match that inserted it, withdrawn when that match stops holding; the default is unchanged. Validity is re-asked of the tuple by TupleMatch, never diffed against a match set -- the streaming conflict set holds only unfired matches, so a diff would read every fired match as gone. Invalidating a justification also clears refraction for it, or the withdrawal is irreversible. Exactly one justification per conclusion: two matches concluding the same thing make two facts, because a logical insert allocates a fresh handle.
  • A window is two decisions, not a feature. There is no window keyword: what a rule matches is bounded by a temporal join's within, what the session keeps is bounded by EvictionPolicy.window, and "as of now" is a Clock fact the caller advances. Composed, they are the velocity rule (accumulate count … before $ref within …, having gte: N-1 — the trigger is not in its own count) with no clock anywhere, and a logical: true conclusion drawn that way withdraws itself when its facts age out, because eviction is an ordinary retract and truth maintenance re-asks the tuple. docs/dsl-guide.md#counting-things-in-a-window is the recipe.
  • Negation still has no truth maintenance unless the conclusion is logical, and must never be used over an evicted type (§4.4). An evicted fact and an absent fact are indistinguishable to a negation, so a cap on the negated type stops costing a firing and starts asserting a false conclusion. This is the sharpest semantic hazard in the engine. MatchExplainer cannot detect it — it re-asks the same question of the same working memory and is fooled identically — but it does warn on any verdict where a rule matched while a type it negates was being evicted, which is the one case the eviction clause belongs on a successful match rather than a silent rule.

The API boundary

-core has ~120 public types and Java has no internal, so "public" means two things: the contract, and reachable-by-a-sibling-package. ApiSurfaceTest (in -testkit) is where that line is drawn — it names the exported packages and the internal ones each module may reach into. Widening it is an edit to that list, not a side effect of typing public.

The boundary is drawn at package granularity, which is what JPMS gives you, so a package that mixes contract with sharing has to be split. match was: Activation and ActivationKey are named by RuleEngineListener, while the six quantifier predicates beside them were public only so a sibling could ask them — those moved to eval. agenda was the same shape in reverse: exporting it to reach ConflictResolutionStrategy would publish Agenda.reactivate and RefractionMemory.forget, so the strategy moved to match and agenda is internal. session was the third and it was fixed at 1.0.0: it held CompiledRuleSet and RuleSession, which are the contract, beside the two classes implementing them — and CompiledRuleSet.network() therefore put the whole compiled node graph on an interface a consumer reads. DefaultCompiledRuleSet and DefaultRuleSession moved to runtime (granted to -compiler, which constructs a rule set), taking the already-package-private RuleSetFingerprint and SessionEvictor with them, and the interface no longer declares network(). SessionIds went too and is the one that was genuinely demoted — public in an exported package only so a sibling could call it, and carrying a scheduled deletion for JDK 26, which after 1.0.0 would have made a fifteen-line stopgap dictate a major version. DefaultRuleSession is package-private now as well; only DefaultCompiledRuleSet needs to be public, because -compiler constructs it. That was pre-publish work and the deadline was real: a method cannot be taken off a published interface inside a major version. ApiSurfaceTest.ALLOWED_LEAKS is now empty and its two assertions are kept, because the mechanism should outlive the debt.

The check that catches this class of error is noExportedSignatureNamesAnInternalType, which reflects over -core's own declared signatures — without it the table is only consistent, not true, and the first draft was wrong about three packages.

JPMS would be better and is deferred. Not because re2j "cannot be required" — the JDK derives an automatic name from the filename and it compiles fine; Gradle just does not put such a jar on the module path. The real reason is that a published descriptor must not requires a filename-derived automatic module, and -cel's dev.cel splits packages across jars, which no naming fixes. §8.1 has the detail. The test pins re2j's packaging by reading the jar — asking the runtime (getModule().isNamed()) reports only where a jar was placed and answers "unnamed" for everything on the classpath, jackson included, which made the first version of that pin unable to fail.

A test that reads files needs its files declared as task inputs. DocExamplesTest, CelDocExamplesTest and ApiSurfaceTest all read outside the classpath; undeclared, Gradle marks the task UP-TO-DATE and the guard silently stops guarding. The three doc fixtures are declared in buildlogic.java-common-conventions.gradle.kts so the next doc-reading test gets them for free; ApiSurfaceTest's seven source trees and the settings.gradle.kts it reads the module list from are declared in rule-engine-testkit/build.gradle.kts, because global they would re-run six other modules' test and strictTest for a comment edit in -cel. CI hides this, because CI always starts clean.

Conventions

  • Java 25, final on parameters and fields, records for the rule/constraint AST, package-info.java in every package.
  • Comments explain why, cite the spec section, and name the alternative that was rejected. Match that density; a comment restating the code is not the house style.
  • Defects found by review get a reproducing test in ReviewRegressionTest before the fix.
  • Doc examples are fixtures, not prose. A rule file in a .md gets compiled by DocExamplesTest.
  • Commit messages: an imperative subject, then prose paragraphs explaining the defect, why it happened, and what the fix chose — not bullet lists of files touched.