This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
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.
| Module | Contents |
|---|---|
rule-engine-core |
Fact model, working memory, all three matchers, agenda, refraction, RHS execution, sessions |
rule-engine-compiler |
RuleDefinition → CompiledRuleSet: 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.
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.
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 withSessionOptions.matching(MatchingStrategy.NAIVE). Never in production.ReteAgenda(rete/, Phase 3) — joins materialised as facts arrive, inBetaMemory, instead of recomputed per fire. Selected withMatchingStrategy.RETE, for long-lived streaming sessions. Shares the join walk withNetworkAgendaviaJoinEnumerator— 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 whypendingByRulemust never be allowed to hold a match that has fired. A §6.4conditionis the exception: rejected matches are never fired, so they are never pulled, and the set drifts toward the join memory. Seedocs/benchmarks.md.NetworkAgenda(network/, Phases 1–2) — the default.EntryNode(per fact type) → sharedAlphaNodes (one per distinct constraint) →PatternNode+ itsPatternMemory→ indexed joins ordered per fire cycle byJoinPlan(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.
- Tuples bind
FactHandles, neverFactobjects. 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 makesexportFacts()/SessionDrain.replayorder by handle id rather than however a map iterates. - Nothing in a
CompiledRuleSetmutates after compile (§5.5, invariant 1). Every scaling claim rests on it, and it is not free:FieldConstraint,RangeConstraintandLiteraldeep-copy theirJsonNodeon the way in but hand back the live node, so a caller reaching a literal throughCompiledRule.source()can mutate a node every session reads. Copying on the way out is not available — the matching path callsliteral()per fact per test.RuleSetFingerprinthashes every mutable value at compile time andnewSession(strict)re-verifies, so violators fail in test; outside strict mode it stays a caller-facing contract. SeeImmutabilityTest.
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:
rules.v1.json(in-dslresources) — structure only: required keys, value types, unknown keys, which keys each action verb accepts. Runs first and hard-stops.OperatorMaps/Actions/References/Quantifiers— what a schema cannot say:$refvs the$$escape vs a rejected$-key, abetweenwith no bound, a malformedalias.field, aquantifierspelling and the §1 answer for the two it does not implement.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).
-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 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.
-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.
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 aBatchOutcomeper 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:publishtakes a compiled rule set so a bad rule file cannot take the engine out of service, and a swap affects new sessions only.SessionEvictor(inruntime/, with the policy SPI inevict/) — §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 ordinaryretractand 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 aHashMapin there is a §7.3 violation that only shows on another host. Three policies ship.leastRecentlyUsedandperTypebound the arrival count;windowbounds 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'swithin. 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 skipOrigin.DERIVEDfacts (the new session re-derives them; replaying would double-count). Refraction state is deliberately not carried over — the handles are new.
- Absent ≠ null.
{ eq: null }matches an explicit JSON null, never an absent field; usehasField: falsefor absence. neis true for an absent field, becauseneis defined as!eq. Pair withhasField: true.iniseqagainst each element (§2.6.1).- Distinct aliases in one rule bind distinct facts. The compiler inserts an implicit inequality
between same-type aliases — and
JoinPlansymmetrises 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/beforeread 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. Thewithinbound is required (an unbounded ordering isgtagainst 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. ACCUMULATEbinds 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, unlikeFOR_ALL. The answer is readable from an action, a §6.4 expression and its ownhaving; nothing may join to it. An absent field is skipped rather than folded as zero, and an empty scope is0forcount/sumand absent formin/max/average.- A quantified pattern (
NOT_EXISTS,FOR_ALL) binds nothing, and nothing may name its alias — not a$ref, not an action, not aninsertFact'sas, 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 joinedFOR_ALLfalse as soon as a second order exists, so the rule can never fire.PatternTestsis where the two halves are asked separately;Negationsconjoins them,Universalsdoes not. - A
FOR_ALLis 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).
insertFactwithlogical: truemakes 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 byTupleMatch, 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 byEvictionPolicy.window, and "as of now" is aClockfact 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 alogical: trueconclusion 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-windowis 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.
MatchExplainercannot 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.
-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.
- Java 25,
finalon 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
ReviewRegressionTestbefore the fix. - Doc examples are fixtures, not prose. A rule file in a
.mdgets compiled byDocExamplesTest. - 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.