Files
2026-05-27 23:29:53 +01:00

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: true and errors.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 errors carries only the first failure

Scenario: depth limit raises ENGINE_DEPTH_EXCEEDED

  • WHEN a rule nests deeper than config.maxDepth
  • THEN the result has passed: false and errors[0].code === "RULES.ENGINE_DEPTH_EXCEEDED"

Scenario: AbortSignal aborts in-flight async evaluation

  • WHEN evaluate is invoked with a pre-aborted AbortSignal
  • THEN the result has passed: false and errors[0].code === "RULES.ENGINE_ABORTED"

Scenario: OTel span emitted per evaluation

  • WHEN evaluate runs against an in-memory MeterProvider
  • THEN rules.engine.evaluate.duration_ms is recorded and rules.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 as evaluate(...).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 ConditionRule with field: "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 LogicalRule with operator: "and" whose second child is a LogicalRule with operator: "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 RulesError with code: "RULES.FIELD_RESOLUTION_FAILED"

Scenario: createRequestContext uses injected Clock for timestamp

  • WHEN createRequestContext({ data: {}, clock: fakeClock(1700000000000) }) is called
  • THEN the resulting context has timestamp equal to new 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(...) against fakeClock
  • THEN total elapsed time is ≈100ms (not ≈300ms) measured against the clock

Scenario: timeout raises ACTION_TIMEOUT

  • WHEN an action with timeoutMs: 50 exceeds 50ms
  • THEN the result has status: "failed" and errorMessage references RULES.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 via fakeClock)
  • THEN the promise rejects with RulesError code: "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 (no undefined)

Scenario: unknown operator raises OPERATOR_NOT_FOUND

  • WHEN defaultOperatorRegistry.get("nonexistent") is called
  • THEN the call throws RulesError with code: "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: false and 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: true and 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: false with 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 RulesError with code: "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 a ConditionRule fixture
  • 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 areRulesEqual returns true

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 and condA and condB are 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 Rule is tested with isConditionRule(rule)
  • THEN the resulting branch types rule as ConditionRule (asserted via expectTypeOf)

Scenario: guards return false for null

  • WHEN any guard is invoked with null
  • THEN the return value is false and 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 RulesError is serialised via the base toWire() adapter and reconstructed via fromWire(...)
  • 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"