23 KiB
Capability Spec Delta: rules (enterprise-rules-migration)
Package: @suaveplan/rules
Status: PROPOSED
Every requirement carries a
**Verified by:**line at authoring time. (AGENTS.md §13.)
ADDED Requirements
Requirement: RuleEngine evaluates rules against a context
The package SHALL expose a RuleEngine class whose evaluate(rule, context, options?) method returns an EvaluationResult for any Rule<TContext> (condition / logical / group / custom / computed). The result SHALL discriminate pass / fail through the passed: boolean field and carry typed RulesError codes for every failure mode (depth exceeded, abort, timeout, operator unknown, field resolution failure).
Scenario: condition rule passes when operator returns true
- WHEN
engine.evaluate({ type: "condition", field: "age", operator: "greaterThan", value: 18 }, { age: 21 })is called - THEN the result has
passed: trueanderrors.length === 0
Scenario: logical AND short-circuits on first false
- WHEN an AND rule contains a failing condition followed by an async condition
- THEN the async condition is not awaited and
errorscarries only the first failure
Scenario: depth limit raises ENGINE_DEPTH_EXCEEDED
- WHEN a rule nests deeper than
config.maxDepth - THEN the result has
passed: falseanderrors[0].code === "RULES.ENGINE_DEPTH_EXCEEDED"
Scenario: AbortSignal aborts in-flight async evaluation
- WHEN
evaluateis invoked with a pre-abortedAbortSignal - THEN the result has
passed: falseanderrors[0].code === "RULES.ENGINE_ABORTED"
Scenario: OTel span emitted per evaluation
- WHEN
evaluateruns against an in-memoryMeterProvider - THEN
rules.engine.evaluate.duration_msis recorded andrules.engine.evaluations_total{outcome}is incremented
Verified by: src/engine/engine.test.ts::"evaluates condition rule" AND src/engine/engine.test.ts::"short-circuits AND on first failure" AND src/engine/engine.test.ts::"raises RULES.ENGINE_DEPTH_EXCEEDED past maxDepth" AND src/engine/engine.test.ts::"aborts when signal is pre-aborted" AND src/engine/engine.spec-conformance.test.ts::"emits rules.engine.evaluate.duration_ms"
Requirement: RuleEngine supports evaluateAll, evaluateRuleset, and check
RuleEngine SHALL expose evaluateAll(rules, context, options?) returning an array of EvaluationResult, evaluateRuleset(ruleset, context, options?) returning a single combined EvaluationResult per the ruleset's operator (and / or), and check(rule, context, options?) returning a Promise<boolean> for callers that only need pass/fail.
Scenario: evaluateAll preserves rule order
- WHEN
evaluateAll([ruleA, ruleB], ctx)is called - THEN the returned array has
[resultA, resultB]in input order
Scenario: ruleset with operator "or" passes if any child passes
- WHEN a ruleset of
{ operator: "or", rules: [failingRule, passingRule] }is evaluated - THEN the combined result has
passed: true
Scenario: check returns boolean without exposing errors
- WHEN
check(rule, ctx)is called - THEN the return type is
Promise<boolean>and resolves to the same value asevaluate(...).passed
Verified by: src/engine/engine.test.ts::"evaluateAll preserves order" AND src/engine/engine.test.ts::"ruleset OR passes when any child passes" AND src/engine/engine.test.ts::"check returns boolean"
Requirement: RuleEngine accepts plugin registrations
RuleEngine SHALL accept RuleEnginePlugin instances via engine.use(plugin) that contribute operators, validators, or actions without forking. Plugins MUST be installed before any evaluation that depends on them.
Scenario: plugin installs a custom operator
- WHEN a plugin registers operator
"divisibleBy"and a condition rule references it - THEN the rule evaluates without raising
RULES.OPERATOR_NOT_FOUND
Scenario: plugin install is idempotent
- WHEN
engine.use(plugin)is called twice with the same plugin - THEN the operator is registered exactly once and no error is thrown
Verified by: src/engine/engine.test.ts::"plugin installs custom operator" AND src/engine/engine.spec-conformance.test.ts::"plugin install is idempotent"
Requirement: RuleBuilder offers a fluent DSL
The package SHALL expose RuleBuilder and FieldBuilder classes plus factory functions rule(), condition(), and(), or(), not(), group() for declarative rule construction. Output SHALL satisfy the Rule discriminated union and round-trip through the serializer.
Scenario: fluent builder produces a Rule
- WHEN
rule("age-check").when("age").greaterThan(18).withMetadata({ priority: "high" }).build()is called - THEN the returned value matches
ConditionRulewithfield: "age",operator: "greaterThan",value: 18,metadata.priority: "high"
Scenario: nested and/or composes
- WHEN
and(condition("a","equals",1), or(condition("b","equals",2), condition("c","equals",3)))is called - THEN the returned tree is a
LogicalRulewithoperator: "and"whose second child is aLogicalRulewithoperator: "or"and two condition children
Verified by: src/builder/builder.test.ts::"fluent builder produces ConditionRule with metadata" AND src/builder/builder.test.ts::"and/or compose into nested LogicalRule"
Requirement: ContextBuilder resolves fields with dot-path and array-index syntax
The package SHALL expose ContextBuilder, DefaultContextProvider, DefaultFieldResolver, and helpers context(), contextWith(), getField(), hasField(), createRequestContext(), createScopedContext(). Field resolution SHALL support dot-path ("user.profile.name") and array-index ("items[0].sku") traversal. The default context provider SHALL take an injectable Clock for timestamp derivation — no new Date() at module scope.
Scenario: dot-path resolves nested field
- WHEN
getField({ user: { profile: { name: "Ada" } } }, "user.profile.name")is called - THEN the return value is
"Ada"
Scenario: array-index resolves indexed element
- WHEN
getField({ items: [{ sku: "X" }, { sku: "Y" }] }, "items[1].sku")is called - THEN the return value is
"Y"
Scenario: missing field raises FIELD_RESOLUTION_FAILED in strict mode
- WHEN
getField({}, "absent", { strict: true })is called - THEN the call throws
RulesErrorwithcode: "RULES.FIELD_RESOLUTION_FAILED"
Scenario: createRequestContext uses injected Clock for timestamp
- WHEN
createRequestContext({ data: {}, clock: fakeClock(1700000000000) })is called - THEN the resulting context has
timestampequal tonew Date(1700000000000)
Verified by: src/context/context.test.ts::"resolves dot-path" AND src/context/context.test.ts::"resolves array-index" AND src/context/context.test.ts::"raises FIELD_RESOLUTION_FAILED in strict" AND src/context/context.test.ts::"uses injected Clock"
Requirement: Action subsystem executes named handlers with timeout and abort
The package SHALL expose Action, ActionRegistry, ActionExecutor plus createAction, createActionRegistry, createActionExecutor, validateActionConfig, and builtInHandlers covering log / set / delay / noop. ActionExecutor SHALL support sequential and parallel execution, configurable timeout per action, and AbortSignal propagation. Timeouts SHALL raise ACTION_TIMEOUT; aborts SHALL raise ACTION_ABORTED. The log handler SHALL accept an injectable LoggerLike (no console.log).
Scenario: parallel executor runs actions concurrently
- WHEN three 100ms actions are run via
executor.executeParallel(...)againstfakeClock - THEN total elapsed time is ≈100ms (not ≈300ms) measured against the clock
Scenario: timeout raises ACTION_TIMEOUT
- WHEN an action with
timeoutMs: 50exceeds 50ms - THEN the result has
status: "failed"anderrorMessagereferencesRULES.ACTION_TIMEOUT
Scenario: pre-aborted signal short-circuits before handler runs
- WHEN
executor.executeSequential([action], { signal: alreadyAbortedSignal })is called - THEN the handler is not invoked and the result reports
RULES.ACTION_ABORTED
Verified by: src/actions/actions.test.ts::"executeParallel runs concurrently" AND src/actions/actions.test.ts::"raises ACTION_TIMEOUT past deadline" AND src/actions/actions.test.ts::"pre-aborted signal short-circuits"
Requirement: EventBus emits typed events with priority hooks and bounded history
The package SHALL expose EventBus, HookManager, createEventBus, createHookManager. The bus SHALL emit typed RuleEngineEvent payloads, maintain a bounded eventHistory (capacity injectable; default 1000), support priority-ordered hook registration, and provide waitFor(eventType, { timeoutMs?, signal? }) returning a Promise<EventPayload>. Timeouts SHALL raise EVENT_TIMEOUT; aborts SHALL raise EVENT_ABORTED.
Scenario: history is bounded by capacity
- WHEN capacity is 3 and 5 events are emitted
- THEN
eventBus.getHistory()returns exactly the last 3 in insertion order
Scenario: higher-priority hooks run first
- WHEN two hooks register for the same event with priorities 10 and 1
- THEN the priority-10 hook is invoked before the priority-1 hook
Scenario: waitFor rejects with EVENT_TIMEOUT past deadline
- WHEN
waitFor("rule.passed", { timeoutMs: 50 })is called and no event fires within the deadline (asserted viafakeClock) - THEN the promise rejects with
RulesErrorcode: "RULES.EVENT_TIMEOUT"
Verified by: src/events/event-bus.test.ts::"history is bounded" AND src/events/event-bus.test.ts::"hooks run in priority order" AND src/events/event-bus.test.ts::"waitFor rejects with EVENT_TIMEOUT"
Requirement: OperatorRegistry exposes 30 built-in operators
The package SHALL expose OperatorRegistry, defaultOperatorRegistry, and 30 built-in operators covering: logical (and, or, not, xor), comparison (equals, notEquals, greaterThan, greaterThanOrEquals, lessThan, lessThanOrEquals, between), collection (in, notIn, contains, notContains, containsAll, containsAny, overlaps), string (startsWith, endsWith, matches), existence (exists, isNull, isNotNull, isEmpty, isNotEmpty, isType, hasProperty), length (lengthEquals, lengthGreaterThan, lengthLessThan). Each operator SHALL implement the OperatorDefinition contract and be registered against defaultOperatorRegistry at module load.
Scenario: every built-in operator is registered on defaultOperatorRegistry
- WHEN
defaultOperatorRegistry.get(name)is called for each of the 30 documented names - THEN every lookup returns an
OperatorDefinition(noundefined)
Scenario: unknown operator raises OPERATOR_NOT_FOUND
- WHEN
defaultOperatorRegistry.get("nonexistent")is called - THEN the call throws
RulesErrorwithcode: "RULES.OPERATOR_NOT_FOUND"
Scenario: between is inclusive on both bounds
- WHEN
betweenOperator.evaluate(5, [5, 10])is called - THEN the return value is
true
Verified by: src/operators/operators.test.ts::"defaultOperatorRegistry contains 30 built-ins" AND src/operators/operators.test.ts::"unknown operator raises OPERATOR_NOT_FOUND" AND src/operators/operators.test.ts::"between is inclusive on both bounds"
Requirement: SchemaValidator validates objects against a JSON-schema-like definition
The package SHALL expose SchemaValidator whose validate(data, options?) returns a ValidationResult covering nested objects, arrays, formats (email/url/uuid/date/datetime/ipv4/ipv6/regex), transforms (trim/lowercase/uppercase/parseNumber/parseDate), and pluggable custom validators. The Schema input SHALL be validated via Zod at the boundary, raising SCHEMA_INVALID on malformed schemas. AbortSignal SHALL short-circuit recursion at every nested-call boundary.
Scenario: email format rejects malformed input
- WHEN
new SchemaValidator({ email: { type: "string", format: "email" } }).validate({ email: "not-an-email" })is called - THEN the result has
valid: falseand one error at path"email"with code referencing format failure
Scenario: transforms run before validation
- WHEN schema declares
{ name: { type: "string", transforms: ["trim", "lowercase"] } }and input is{ name: " ADA " } - THEN the result has
valid: trueand the transformed value"ada"is reported
Verified by: src/validators/schema/schema.test.ts::"email format rejects malformed" AND src/validators/schema/schema.test.ts::"transforms run before validation"
Requirement: FieldValidator offers a chainable validator DSL with 12 preset chains
The package SHALL expose FieldValidator, createFieldValidator, field(), and 12 preset chainable validators (emailFieldValidator, passwordFieldValidator, usernameFieldValidator, phoneFieldValidator, urlFieldValidator, uuidFieldValidator, dateFieldValidator, integerFieldValidator, ageValidator, currencyValidator, percentageValidator, positiveNumberValidator). The chain SHALL be immutable per call and support sync validateSync (throws if async stages exist) and async validate(value, { signal? }).
Scenario: passwordFieldValidator accepts a strong password
- WHEN
passwordFieldValidator().validate("ABCdef123!")is called - THEN the result has
valid: true
Scenario: chain rejects short username
- WHEN
field().minLength(3).validate("ab")is called - THEN the result has
valid: falsewith one error referencing min-length
Scenario: validateSync throws when chain has async stage
- WHEN a chain contains an async stage and
validateSync(value)is called - THEN the call throws
RulesErrorwithcode: "RULES.VALIDATOR_INVALID"
Verified by: src/validators/field/field.test.ts::"passwordFieldValidator accepts strong" AND src/validators/field/field.test.ts::"chain rejects short username" AND src/validators/field/field.test.ts::"validateSync throws on async stage"
Requirement: CustomValidator + ValidatorRegistry expose 17 built-in validators
The package SHALL expose CustomValidator, ValidatorRegistry, defaultValidatorRegistry, createValidator, composeValidators, and 17 built-in validators (emailValidator, urlValidator, uuidValidator, isoDateValidator, creditCardValidator, phoneValidator, strongPasswordValidator, alphanumericValidator, hexColorValidator, slugValidator, jsonValidator, enumValidator, lengthValidator, patternValidator, rangeValidator, asyncValidator, composeValidators). creditCardValidator SHALL implement the Luhn algorithm.
Scenario: creditCardValidator accepts a valid Luhn-checksum number
- WHEN
creditCardValidator.validate("4111111111111111")is called - THEN the result has
valid: true
Scenario: composeValidators short-circuits on first failure
- WHEN
composeValidators([failingValidator, throwingValidator]).validate(x)is called - THEN the throwing validator is not invoked and the failure carries the first validator's error
Scenario: defaultValidatorRegistry contains 17 built-ins
- WHEN
defaultValidatorRegistry.list()is called - THEN the returned names cover the 17 documented validators
Verified by: src/validators/custom/custom.test.ts::"creditCardValidator accepts valid Luhn" AND src/validators/custom/custom.test.ts::"composeValidators short-circuits" AND src/validators/custom/custom.test.ts::"defaultValidatorRegistry contains 17 built-ins"
Requirement: Serializer round-trips rules through JSON with function-placeholder strategy
The package SHALL expose serializeRule, serializeRuleset, serializeRuleToObject, serializeRulesetToObject, deserializeRule, deserializeRuleset, deserializeRuleFromObject, deserializeRulesetFromObject, cloneRule, hashRule, areRulesEqual, validateSerializedRule. SerializedRule SHALL be Zod-validated at the boundary. Function-valued fields (CustomRule.validate, ComputedRule.compute) SHALL serialize to a documented placeholder marker and deserialize through a caller-supplied resolver; missing-resolver SHALL raise DESERIALIZE_FAILED.
Scenario: ConditionRule round-trips byte-for-byte
- WHEN
deserializeRule(serializeRule(rule))is called for aConditionRulefixture - THEN the resulting rule deep-equals the input
Scenario: hashRule is order-insensitive over commutative operators
- WHEN
hashRule(and(A, B)) === hashRule(and(B, A)) - THEN the equality holds
Scenario: areRulesEqual handles nested structures
- WHEN two structurally-identical nested AND/OR trees with reordered commutative children are compared
- THEN
areRulesEqualreturnstrue
Verified by: src/serializer/serializer.test.ts::"ConditionRule round-trips" AND src/serializer/serializer.test.ts::"hashRule is commutative-order-insensitive" AND src/serializer/serializer.test.ts::"areRulesEqual handles nested"
Requirement: Optimizer simplifies and analyses rule trees
The package SHALL expose optimizeRule, analyzeRule, estimateCost, findFields, hasAsyncComponents, countRules. Optimization SHALL flatten nested same-operator logical rules (AND-of-AND → flat AND), dedupe structurally-equal children, simplify constant-true / constant-false branches, and sort children by estimated cost ascending for short-circuit benefit. analyzeRule SHALL return RuleStats { totalRules, maxDepth, fields, hasAsync, estimatedCost }.
Scenario: flatten collapses nested AND-of-AND
- WHEN
optimizeRule(and(and(A, B), C))is called - THEN the result is
and(A, B, C)(depth-1 AND with three children)
Scenario: dedupe removes structurally-equal duplicates
- WHEN
optimizeRule(and(condA, condA, condB))is called andcondAandcondBare distinct - THEN the result is
and(condA, condB)
Scenario: countRules walks the full tree
- WHEN
countRules(and(condA, or(condB, condC)))is called - THEN the return value is
5
Verified by: src/optimizer/optimizer.test.ts::"flatten collapses nested AND" AND src/optimizer/optimizer.test.ts::"dedupe removes structural duplicates" AND src/optimizer/optimizer.test.ts::"countRules walks full tree"
Requirement: Type guards discriminate the Rule union
The package SHALL expose isConditionRule, isLogicalRule, isGroupRule, isCustomRule, isComputedRule. Each guard SHALL narrow the Rule discriminated union by type and SHALL return false for null / undefined / non-object input rather than throwing.
Scenario: isConditionRule narrows the type at compile time
- WHEN a
Ruleis tested withisConditionRule(rule) - THEN the resulting branch types
ruleasConditionRule(asserted viaexpectTypeOf)
Scenario: guards return false for null
- WHEN any guard is invoked with
null - THEN the return value is
falseand no exception is thrown
Verified by: src/types/guards.test.ts::"isConditionRule narrows" AND src/types/guards.spec-conformance.test.ts::"guards return false for null"
Requirement: RulesError exposes a typed code registry
The package SHALL expose RulesError extends SuaveplanError<RulesErrorCode> with defineCodes("RULES", [...]) covering 24 codes: ENGINE_DEPTH_EXCEEDED, ENGINE_EVALUATION_FAILED, ENGINE_ABORTED, ENGINE_TIMEOUT, OPERATOR_NOT_FOUND, OPERATOR_INVALID, FIELD_RESOLUTION_FAILED, CONTEXT_INVALID, ACTION_TIMEOUT, ACTION_ABORTED, ACTION_HANDLER_FAILED, ACTION_NOT_FOUND, SCHEMA_INVALID, VALIDATOR_INVALID, VALIDATOR_NOT_FOUND, EVENT_TIMEOUT, EVENT_ABORTED, HOOK_INVALID, SERIALIZE_FAILED, DESERIALIZE_FAILED, RULE_INVALID, RULESET_INVALID, OPTIMIZER_FAILED, PLUGIN_FAILED. Every code SHALL be thrown by at least one production code path (verified at gate time).
Scenario: every code in the registry is thrown by ≥1 production path
- WHEN the openspec-conformance script scans
src/for every code - THEN every code has at least one originating
throw new RulesError(...)site outside tests
Scenario: SuaveplanError wire format round-trips
- WHEN a
RulesErroris serialised via the basetoWire()adapter and reconstructed viafromWire(...) - THEN code, message, and cause-chain match the original
Verified by: src/errors/rules-error.test.ts::"every code is thrown by a production path" AND src/errors/rules-error.spec-conformance.test.ts::"SuaveplanError wire format round-trips"
Requirement: All public boundaries validate input via Zod
Every public function and class constructor whose input is a structured object SHALL validate that input against a Zod schema at the boundary, raising the appropriate typed code (e.g. RULE_INVALID, RULESET_INVALID, SCHEMA_INVALID, CONTEXT_INVALID) when the schema rejects.
Scenario: malformed Rule input rejected at engine.evaluate
- WHEN
engine.evaluate({ type: "condition" /* missing field */ } as any, {})is called - THEN the result reports
RULES.RULE_INVALID
Verified by: src/engine/engine.spec-conformance.test.ts::"rejects malformed Rule with RULE_INVALID"
Requirement: OTel instruments emit from production paths
The package SHALL emit the documented OTel surface (rules.engine.evaluate.duration_ms, rules.engine.evaluations_total{outcome}, rules.engine.depth, rules.validator.validate.duration_ms{kind}, rules.validator.validations_total{kind,outcome}, rules.serializer.duration_ms{op}, rules.action.execute.duration_ms, rules.action.executions_total{status}, rules.event.emitted_total{type}, rules.operator.unknown_total{name}) from production code paths, asserted against an in-memory MeterProvider (no vi.mock).
Scenario: every documented instrument emits at least once across the test suite
- WHEN the full test suite runs against an in-memory
MeterProvider - THEN each documented instrument has at least one recorded measurement
Verified by: src/engine/engine.spec-conformance.test.ts::"emits documented OTel surface"
Requirement: Package ships subpath exports per feature module
The package SHALL declare subpath exports in package.json for ./engine, ./builder, ./context, ./actions, ./events, ./operators, ./validators/schema, ./validators/field, ./validators/custom, ./serializer, ./optimizer, ./types, ./errors, each pointing to the feature's dist/<path>/index.{d.ts,js}. The package SHALL set "sideEffects": false.
Scenario: each subpath resolves a real entrypoint
- WHEN
node --experimental-resolve-self -e 'import "@suaveplan/rules/engine"'runs against the built package - THEN the import succeeds
Verified by: src/__exports__/exports.test.ts::"every subpath export resolves"