# 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` (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` 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` 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`. 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` 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//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"`