Initial Commit
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# Enterprise migration — @suaveplan/rules + @suaveplan/rules-hono
|
||||
|
||||
**Package**: `@suaveplan/rules`, `@suaveplan/rules-hono`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (migration + enterprise harden)
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/rules` is on the genesis Pending list (workflow plugin). Upstream ships a 63-file, ~17.5k-LOC rule engine (rule engine + builder + context + 30+ operators + 3 validator families + serializer + optimizer + action subsystem + event bus + Hono middleware) at 92.87% coverage and no Zod/OTel/SuaveplanError integration. Genesis ships every package at the bar set by `crypto-rsa` / `webauthn` / `srp`: 100% coverage, sibling `.md` per impl, Zod at every boundary, OTel hot-path instrumentation, `defineCodes` error registry extending `SuaveplanError`, injectable `Clock`/`Rng`, `AbortSignal` threaded through every async surface. This migration brings the package across at that bar in a single change rather than landing a parity port and a follow-up harden.
|
||||
|
||||
The Hono middleware is split into a sibling workflow-tier package `@suaveplan/rules-hono`. The core rules engine stays universal-tier with zero Node/browser/framework deps; the Hono integration lives next to its peer so consumers who do not use Hono pay nothing for it. This is the same split pattern used for `crypto-rsa` / `crypto-bin` / `crypto-cli` (core primitive vs. framework consumer).
|
||||
|
||||
## What changes
|
||||
|
||||
### @suaveplan/rules (universal-tier core)
|
||||
|
||||
- ADDED: `RuleEngine` — async evaluator with `evaluate` / `evaluateAll` / `evaluateRuleset` / `check`, depth limit, evaluation cache, AbortSignal, OTel spans + histograms, error results carrying `RulesError` codes
|
||||
- ADDED: `RuleBuilder` / `FieldBuilder` fluent DSL — `rule()` / `condition()` / `and()` / `or()` / `not()` / `group()` factory functions
|
||||
- ADDED: `ContextBuilder` / `DefaultContextProvider` / `DefaultFieldResolver` / `createRequestContext` / `createScopedContext` / `getField` / `hasField` / `context` / `contextWith`
|
||||
- ADDED: `Action` / `ActionRegistry` / `ActionExecutor` with `createAction` / `createActionRegistry` / `createActionExecutor` / `validateActionConfig` / `builtInHandlers` (log/set/delay/noop), timeout + AbortSignal, sequential + parallel execution
|
||||
- ADDED: `EventBus` / `HookManager` with `createEventBus` / `createHookManager`, bounded history (injectable `Clock`), priority hooks, `waitFor` with timeout + abort
|
||||
- ADDED: `OperatorRegistry` + 30 built-in operators (`equalsOperator`, `notEqualsOperator`, `greaterThanOperator`, `greaterThanOrEqualsOperator`, `lessThanOperator`, `lessThanOrEqualsOperator`, `betweenOperator`, `inOperator`, `notInOperator`, `containsOperator`, `notContainsOperator`, `containsAllOperator`, `containsAnyOperator`, `overlapsOperator`, `startsWithOperator`, `endsWithOperator`, `matchesOperator`, `existsOperator`, `isNullOperator`, `isNotNullOperator`, `isEmptyOperator`, `isNotEmptyOperator`, `isTypeOperator`, `hasPropertyOperator`, `lengthEqualsOperator`, `lengthGreaterThanOperator`, `lengthLessThanOperator`, `andOperator`, `orOperator`, `notOperator`, `xorOperator`) + `defaultOperatorRegistry`
|
||||
- ADDED: `SchemaValidator` — JSON-schema-style recursive validator with formats (email/url/uuid/date/datetime/regex/ipv4/ipv6), transforms (trim/lowercase/uppercase/parseNumber/parseDate), nested objects, arrays, custom validator hooks
|
||||
- ADDED: `FieldValidator` + `field()` + 12 preset chainable validators (`emailFieldValidator`, `passwordFieldValidator`, `usernameFieldValidator`, `phoneFieldValidator`, `urlFieldValidator`, `uuidFieldValidator`, `dateFieldValidator`, `integerFieldValidator`, `ageValidator`, `currencyValidator`, `percentageValidator`, `positiveNumberValidator`) + `createFieldValidator`
|
||||
- ADDED: `CustomValidator` + `ValidatorRegistry` + 17 built-in validators (`emailValidator`, `urlValidator`, `uuidValidator`, `isoDateValidator`, `creditCardValidator` w/ Luhn, `phoneValidator`, `strongPasswordValidator`, `alphanumericValidator`, `hexColorValidator`, `slugValidator`, `jsonValidator`, `enumValidator`, `lengthValidator`, `patternValidator`, `rangeValidator`, `asyncValidator`, `composeValidators`) + `createValidator` + `defaultValidatorRegistry`
|
||||
- ADDED: Serializer — `serializeRule` / `serializeRuleset` / `serializeRuleToObject` / `serializeRulesetToObject` / `deserializeRule` / `deserializeRuleset` / `deserializeRuleFromObject` / `deserializeRulesetFromObject` / `cloneRule` / `hashRule` / `areRulesEqual` / `validateSerializedRule` with function-placeholder strategy for `CustomRule.validate` / `ComputedRule.compute`
|
||||
- ADDED: Optimizer — `optimizeRule` / `analyzeRule` / `estimateCost` / `findFields` / `hasAsyncComponents` / `countRules` (flatten nested AND/OR, dedupe, simplify constant branches, sort for short-circuit)
|
||||
- ADDED: Type guards — `isConditionRule` / `isLogicalRule` / `isGroupRule` / `isCustomRule` / `isComputedRule`
|
||||
- ADDED: `RulesError` extending `SuaveplanError` with `defineCodes("RULES", [...])` registry covering: `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`
|
||||
- ADDED: Subpath exports — `./engine`, `./builder`, `./context`, `./actions`, `./events`, `./operators`, `./validators/schema`, `./validators/field`, `./validators/custom`, `./serializer`, `./optimizer`, `./types`, `./errors`
|
||||
- ADDED: 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}`
|
||||
|
||||
### @suaveplan/rules-hono (workflow-tier consumer)
|
||||
|
||||
- ADDED: `ruleMiddleware` / `validationMiddleware` / `accessControl` / `responseValidation` / `rateLimitRule` / `combineRules` over Hono `MiddlewareHandler`
|
||||
- ADDED: `RuleMiddlewareOptions` / `ValidationMiddlewareOptions` / `AccessControlOptions` / `HonoValidationError` types
|
||||
- ADDED: Zod-validated options; default singleton `RuleEngine` opt-in (not module-level); explicit `engine` injection encouraged
|
||||
- ADDED: Subpath exports — `./middleware`, `./types`
|
||||
- ADDED: OTel spans `rules_hono.middleware.<name>.duration_ms{outcome}`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Storybook / E2E** — engine + validators are headless universal-tier; Hono middleware tested via in-process Hono `app.request` (no browser/E2E surface).
|
||||
- **`@suaveplan/schema-registry` integration** — enterprise plan §3 lists this; deferring because the schema-registry use case for `rules` is rule-format evolution across multiple deployed services, not present in genesis-internal consumers yet. Tracked separately when a consumer needs it.
|
||||
- **Feature flags via `@suaveplan/feature-flags`** — enterprise plan §3 lists this; the engine itself does not have risky-rollout entry points. Consumers compose feature-flags with rules where they need conditional rollouts.
|
||||
- **Codemod for upstream callers** — genesis is a fresh branch; no upstream consumers depend on `@suaveplan/rules` from genesis yet. CHANGELOG entry covers the API delta for documentation.
|
||||
- **i18n keying of error messages** — error codes are i18n-ready (typed discriminants per `@suaveplan/error` contract); user-facing message strings remain English-only in this change. Translation catalogues land per-consumer.
|
||||
|
||||
## Parity checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `RuleEngine` evaluate/evaluateAll/evaluateRuleset/check | ✅ | ⬆️ | Enhanced: AbortSignal, OTel, RulesError codes, depth limit enforced via typed code |
|
||||
| RuleBuilder + FieldBuilder + factories | ✅ | ✅ | |
|
||||
| ContextBuilder + DefaultContextProvider + DefaultFieldResolver | ✅ | ⬆️ | Enhanced: injectable `Clock`, no `new Date()` at module scope |
|
||||
| Action / ActionRegistry / ActionExecutor | ✅ | ⬆️ | Enhanced: AbortSignal threaded into `execute`, OTel histogram, typed `ACTION_TIMEOUT` / `ACTION_ABORTED` codes |
|
||||
| EventBus + HookManager + waitFor | ✅ | ⬆️ | Enhanced: AbortSignal on `waitFor`, bounded history via injectable `Clock`, typed `EVENT_TIMEOUT` / `EVENT_ABORTED` codes |
|
||||
| OperatorRegistry + 30 built-ins | ✅ | ✅ | |
|
||||
| SchemaValidator (JSON-schema-like) | ✅ | ⬆️ | Enhanced: Zod-validated `Schema` boundary, OTel histogram |
|
||||
| FieldValidator + 12 preset chains | ✅ | ⬆️ | Enhanced: AbortSignal on `validate`, OTel histogram |
|
||||
| CustomValidator + 17 built-in validators + ValidatorRegistry | ✅ | ⬆️ | Enhanced: AbortSignal on async validators, OTel histogram |
|
||||
| Serializer (serialize/deserialize/clone/hash/equality) | ✅ | ⬆️ | Enhanced: Zod-validated `SerializedRule` boundary, function-placeholder strategy documented per RFC 8259 |
|
||||
| Optimizer (flatten/dedupe/simplify/sort/analyze/estimateCost/findFields) | ✅ | ✅ | |
|
||||
| Type guards (isConditionRule etc.) | ✅ | ✅ | |
|
||||
| Hono middleware (6 functions) | ✅ | 🔄 | Promoted to sibling `@suaveplan/rules-hono` per user direction; surface 1:1 |
|
||||
| Module-level singleton `defaultEngine` in middleware | ✅ | 🚫 | Scoped out: module-level singleton is a hidden global. Consumers pass `engine` explicitly or call `getDefaultEngine()` lazily (memoised per-process). |
|
||||
| `console.log` builtin handler | ✅ | ⬆️ | Replaced with `LoggerLike` injection — `console.log` is banned in genesis src/ per AGENTS.md §2 |
|
||||
| `Math.random` / `Date.now` at module scope | ✅ | ⬆️ | Replaced with injectable `Clock` / `Rng` from `@suaveplan/types` |
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/rules.md` and the upstream source at `../suaveplan/packages/universal/rules/`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| Zero browser/Node/React deps | enterprise/rules.md L37 | verbatim — preserved by keeping rules universal-tier |
|
||||
| Zod schemas at public boundaries | enterprise/rules.md L38 | verbatim |
|
||||
| Property-based tests for pure functions | enterprise/rules.md L39 | applied to operators + optimizer + serializer round-trips |
|
||||
| Deterministic behaviour — no `Math.random`/`Date.now` at module scope | enterprise/rules.md L40 | verbatim — drives `Clock`/`Rng` injection |
|
||||
| OTel instrumentation via `@suaveplan/opentelemetry` for all public entrypoints | enterprise/rules.md L41 | verbatim |
|
||||
| Structured errors extending `@suaveplan/error` with stable codes | enterprise/rules.md L42 | drives `RulesError` registry |
|
||||
| AbortSignal everywhere async | enterprise/rules.md L43 | verbatim |
|
||||
| i18n-ready, all messages keyed | enterprise/rules.md L46 | drives typed code discriminants (messages translatable per code) |
|
||||
| Test coverage 100% | enterprise/rules.md L50 | verbatim |
|
||||
| Docs coverage 100% per `docs:check` | enterprise/rules.md L51 | verbatim — sibling `.md` per impl |
|
||||
| File-size budget 300 hard / 500 test | enterprise/rules.md L52 | verbatim |
|
||||
| No mocks — real services | enterprise/rules.md L53 | applied via injectable interfaces (Clock/Rng/Logger), no external IO in rules itself |
|
||||
| `sideEffects: false` tree-shake | enterprise/rules.md L55 | verbatim |
|
||||
| Plugin/extension interface | enterprise/rules.md L69 | applied to `OperatorRegistry` + `ValidatorRegistry` + `ActionRegistry` + `RuleEnginePlugin` |
|
||||
| Observability surface — counters/histograms/spans | enterprise/rules.md L70 | drives OTel instrument list above |
|
||||
| Benchmark suite in `src/__benchmarks__` | enterprise/rules.md L72 | mitata benchmarks shipped per AGENTS.md §9 |
|
||||
| Hono middleware | upstream src/middleware/hono/hono.ts L1-L350 | promoted to sibling package per user direction |
|
||||
| 30 built-in operators | upstream src/operators/operator-registry/operator-registry.ts L1-L682 | full surface preserved |
|
||||
| 17 custom-validator built-ins | upstream src/validators/custom-validator/custom-validator.ts | full surface preserved |
|
||||
| 12 field-validator preset chains | upstream src/validators/field-validator/field-validator.ts | full surface preserved |
|
||||
| Serializer function-placeholder strategy | upstream src/utils/rule-serializer/rule-serializer.ts | preserved; documented as a deliberate limitation |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `RulesError` code registry of 24 codes | Genesis policy — every error path discriminable. Upstream returns ad-hoc `RuleError` objects without a stable code set; genesis maps every failure mode to a typed code. |
|
||||
| Subpath exports per feature module | Genesis tree-shaking policy — AGENTS.md §6. |
|
||||
| Pattern B feature-module layout | Genesis structure policy — CLAUDE.md "Package Structure" section. |
|
||||
| `@suaveplan/testing/runner` for every test import | Genesis cross-runner policy — AGENTS.md §7, CLAUDE.md rule 11. |
|
||||
| Explicit named re-exports in feature barrels | Genesis barrel policy — AGENTS.md §6a, CLAUDE.md rule 10. |
|
||||
| Removal of module-level `defaultEngine` singleton | Hidden globals across module boundaries violate the deterministic / injection-first design. Replaced with explicit `engine` parameter or lazy `getDefaultEngine()`. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in `specs/rules/spec.md` and `specs/rules-hono/spec.md` carries a `**Verified by:**` line at authoring time per AGENTS.md §13. Test files:
|
||||
|
||||
**rules**:
|
||||
|
||||
- `src/engine/engine.test.ts` — engine evaluation, depth limit, AbortSignal, OTel emission
|
||||
- `src/engine/engine.spec-conformance.test.ts` — RulesError code coverage, type-level invariants
|
||||
- `src/builder/builder.test.ts` — fluent DSL round-trip
|
||||
- `src/context/context.test.ts` — field resolution (dot path / array index), request-context derivation
|
||||
- `src/actions/actions.test.ts` — sequential/parallel, timeout, AbortSignal, registry
|
||||
- `src/events/event-bus.test.ts` — emission, priority hooks, bounded history, waitFor + abort
|
||||
- `src/operators/operators.test.ts` — all 30 operators, property-based for commutative/associative ones
|
||||
- `src/validators/schema/schema.test.ts` — full format + transform + nested coverage
|
||||
- `src/validators/field/field.test.ts` — preset chains + chainable surface
|
||||
- `src/validators/custom/custom.test.ts` — every built-in including Luhn
|
||||
- `src/serializer/serializer.test.ts` — round-trip property tests, function-placeholder behaviour
|
||||
- `src/optimizer/optimizer.test.ts` — flatten/dedupe/simplify property tests
|
||||
- `src/errors/rules-error.test.ts` — every code thrown by ≥1 production path
|
||||
|
||||
**rules-hono**:
|
||||
|
||||
- `src/middleware/rule.test.ts`
|
||||
- `src/middleware/validation.test.ts`
|
||||
- `src/middleware/access-control.test.ts`
|
||||
- `src/middleware/response-validation.test.ts`
|
||||
- `src/middleware/rate-limit-rule.test.ts`
|
||||
- `src/middleware/combine.test.ts`
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No genesis consumers of `@suaveplan/rules` exist yet (Pending status per STATUS.md). Future consumers (`form-builder`, `workflow`) will import directly; this change is published before they land.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/rules`.
|
||||
|
||||
- No dependents on genesis (Pending → Completed). New package — no behavior change to propagate.
|
||||
- `@suaveplan/rules-hono` is created in this same change, depends on `@suaveplan/rules` `workspace:^`.
|
||||
@@ -0,0 +1,152 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: rules-hono (enterprise-rules-migration)
|
||||
|
||||
**Package**: `@suaveplan/rules-hono`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement carries a `**Verified by:**` line at authoring time. (AGENTS.md §13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ruleMiddleware evaluates a single rule per request
|
||||
|
||||
The package SHALL expose `ruleMiddleware<TContext>(rule, options)` returning a Hono `MiddlewareHandler`. The middleware SHALL invoke `options.extractData?` to build the evaluation context (defaulting to a context derived from `c.req`), call `options.engine ?? getDefaultEngine()` to evaluate, store the `EvaluationResult` in `c.set("ruleResult", result)`, and call `options.onDenied?` (or short-circuit with 403) when the rule fails.
|
||||
|
||||
#### Scenario: passes through to next handler when rule passes
|
||||
|
||||
- **WHEN** a Hono app uses `ruleMiddleware(passingRule)` and a request is dispatched
|
||||
- **THEN** the next handler is invoked and `c.var.ruleResult.passed === true`
|
||||
|
||||
#### Scenario: 403 short-circuit when rule fails
|
||||
|
||||
- **WHEN** a Hono app uses `ruleMiddleware(failingRule)` and no `onDenied` is supplied
|
||||
- **THEN** the response status is 403 and the next handler is not invoked
|
||||
|
||||
**Verified by:** `src/middleware/rule.test.ts::"passes through on rule pass"` AND `src/middleware/rule.test.ts::"403 short-circuits on rule fail"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: validationMiddleware validates request body, query, params, and headers
|
||||
|
||||
The package SHALL expose `validationMiddleware(options)` accepting per-source schemas under `body` / `query` / `params` / `headers`. The middleware SHALL collect errors per source as `HonoValidationError`, call `options.onError?` (or short-circuit with 400 returning a JSON body) when any errors exist, and honour `abortEarly` to stop at the first failing source.
|
||||
|
||||
#### Scenario: body schema rejects malformed JSON
|
||||
|
||||
- **WHEN** a request with `Content-Type: application/json` and a body that fails the body schema is dispatched
|
||||
- **THEN** the response status is 400 and the body lists every failing field with `source: "body"`
|
||||
|
||||
#### Scenario: abortEarly stops at first failing source
|
||||
|
||||
- **WHEN** body and query both fail and `abortEarly: true` is set
|
||||
- **THEN** the response body lists only `body` errors
|
||||
|
||||
**Verified by:** `src/middleware/validation.test.ts::"body schema rejects malformed JSON"` AND `src/middleware/validation.test.ts::"abortEarly stops at first failing source"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: accessControl evaluates a ruleset with caching
|
||||
|
||||
The package SHALL expose `accessControl<TContext>(options)` taking a single rule or a ruleset, optionally an injectable `cache: { get(k): Result | undefined; set(k, v, ttl): void }` keyed by `options.cacheKey?(c)`. On pass, the result SHALL be stored in `c.set("accessResult", result)`; on fail, `options.onDenied?` runs or a 403 is returned.
|
||||
|
||||
#### Scenario: cache hit short-circuits evaluation
|
||||
|
||||
- **WHEN** the cache returns a passed `EvaluationResult` for the computed key
|
||||
- **THEN** the rule engine is not invoked and the next handler runs with `c.var.accessResult` populated from cache
|
||||
|
||||
#### Scenario: 403 on ruleset denial includes errors
|
||||
|
||||
- **WHEN** a ruleset evaluates to `passed: false`
|
||||
- **THEN** the response status is 403 and the JSON body includes the `errors` array
|
||||
|
||||
**Verified by:** `src/middleware/access-control.test.ts::"cache hit short-circuits engine"` AND `src/middleware/access-control.test.ts::"403 includes ruleset errors"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: responseValidation validates outgoing JSON bodies
|
||||
|
||||
The package SHALL expose `responseValidation(options)` that runs AFTER the downstream handler. The middleware SHALL parse the response body when `content-type` is `application/json`, validate it against `options.schema`, and call `options.onError?` (or replace the response with a 500 and a JSON envelope) when validation fails.
|
||||
|
||||
#### Scenario: invalid response body replaced with 500 envelope
|
||||
|
||||
- **WHEN** the downstream handler returns JSON that fails the schema
|
||||
- **THEN** the final response has status 500 and a JSON body with `errors`
|
||||
|
||||
**Verified by:** `src/middleware/response-validation.test.ts::"invalid body replaced with 500"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: rateLimitRule returns 429 when its rule fails
|
||||
|
||||
The package SHALL expose `rateLimitRule<TContext>(options)` evaluating `options.rules` against a context built by `options.extractContext`. On failure, the response SHALL be 429 with a `Retry-After` header when `options.retryAfterSeconds?` is provided, optionally allowing `options.onLimited?` to customise the response.
|
||||
|
||||
#### Scenario: 429 with Retry-After when over limit
|
||||
|
||||
- **WHEN** the rule fails and `retryAfterSeconds: 60` is configured
|
||||
- **THEN** the response status is 429 and `Retry-After: 60`
|
||||
|
||||
**Verified by:** `src/middleware/rate-limit-rule.test.ts::"429 with Retry-After"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: combineRules chains middlewares with early-response short-circuit
|
||||
|
||||
The package SHALL expose `combineRules(...middlewares)` returning a single `MiddlewareHandler`. The combined middleware SHALL run each child sequentially, short-circuit on the first child that writes a response (no further children run), and pass control to the next handler if all children pass.
|
||||
|
||||
#### Scenario: first early response short-circuits
|
||||
|
||||
- **WHEN** the first child sends a 403 response
|
||||
- **THEN** the second child is not invoked
|
||||
|
||||
#### Scenario: all-pass chains through
|
||||
|
||||
- **WHEN** every child passes through
|
||||
- **THEN** the next handler runs and observes context variables set by each child
|
||||
|
||||
**Verified by:** `src/middleware/combine.test.ts::"first response short-circuits"` AND `src/middleware/combine.test.ts::"all-pass chains through"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Default engine is lazy and per-process
|
||||
|
||||
The package SHALL expose `getDefaultEngine()` returning a process-singleton `RuleEngine` constructed on first call (no module-level instantiation). Tests SHALL be able to reset the singleton via `resetDefaultEngine()` (exported under the `./testing` subpath only).
|
||||
|
||||
#### Scenario: first call constructs the singleton
|
||||
|
||||
- **WHEN** `getDefaultEngine()` is called twice
|
||||
- **THEN** the second call returns the identical reference (`===`)
|
||||
|
||||
#### Scenario: resetDefaultEngine forces reconstruction
|
||||
|
||||
- **WHEN** `resetDefaultEngine()` is called, then `getDefaultEngine()` returns a fresh instance
|
||||
- **THEN** the new instance is not `===` to the previous one
|
||||
|
||||
**Verified by:** `src/engine/default.test.ts::"singleton identity"` AND `src/engine/default.test.ts::"resetDefaultEngine forces reconstruction"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: All middleware options are validated via Zod at construction
|
||||
|
||||
Every middleware factory SHALL Zod-validate its `options` argument at call time and raise `RulesError` with `code: "RULES.PLUGIN_FAILED"` (re-used from the rules core for option-validation failures) when validation rejects.
|
||||
|
||||
#### Scenario: validationMiddleware rejects malformed options
|
||||
|
||||
- **WHEN** `validationMiddleware({ body: "not-a-schema" } as any)` is called
|
||||
- **THEN** the call throws `RulesError` `code: "RULES.PLUGIN_FAILED"`
|
||||
|
||||
**Verified by:** `src/middleware/options.spec-conformance.test.ts::"rejects malformed options"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OTel surface emits from middleware paths
|
||||
|
||||
The package SHALL emit `rules_hono.middleware.<name>.duration_ms{outcome}` and `rules_hono.middleware.<name>_total{outcome}` for each of the six middleware factories, asserted via an in-memory `MeterProvider`.
|
||||
|
||||
#### Scenario: every middleware emits its duration histogram
|
||||
|
||||
- **WHEN** the full test suite runs against an in-memory `MeterProvider`
|
||||
- **THEN** each of the six middlewares has at least one recorded `duration_ms` measurement
|
||||
|
||||
**Verified by:** `src/middleware/otel.spec-conformance.test.ts::"every middleware emits duration_ms"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,384 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
145
openspec/archive/2026-05-11-enterprise-rules-migration/tasks.md
Normal file
145
openspec/archive/2026-05-11-enterprise-rules-migration/tasks.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Tasks — enterprise-rules-migration
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Retroactive ticking is the failure mode this template guards against.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/rules.md` end-to-end
|
||||
- [x] 1.2 No prior `openspec/specs/rules/spec.md` exists — new capability
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from templates/proposal.md
|
||||
- [x] 1.5 Source mapping table cites enterprise/rules.md lines for every requirement
|
||||
- [x] 1.6 Draft `specs/rules/spec.md` and `specs/rules-hono/spec.md`
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line
|
||||
|
||||
## 2. Implementation — @suaveplan/rules
|
||||
|
||||
### 2.1 Package scaffold — ✅ landed 2026-05-11
|
||||
|
||||
- [x] 2.1.1 `packages/workflow/rules/package.json` (catalog deps + workspace `@suaveplan/error`, `events`, `types`)
|
||||
- [x] 2.1.2 `tsconfig.json` + `tsconfig.lib.json` extend `tsconfig.base.json`
|
||||
- [x] 2.1.3 `vite.config.ts` (preserveModules, dts)
|
||||
- [x] 2.1.4 `vitest.config.ts` (istanbul, 100/100/100/100 thresholds)
|
||||
- [x] 2.1.5 Subpath exports declared for every feature module (engine, builder, context, actions, events, operators, validators/{schema,field,custom}, serializer, optimizer, types, errors)
|
||||
|
||||
### 2.2 Types + errors + constants — ✅ landed 2026-05-11
|
||||
|
||||
- [x] 2.2.1 `src/types/` — rule.types, context.types, operator.types, action.types, event.types, validator.types, result.types, guards.ts, index.ts
|
||||
- [x] 2.2.2 `src/errors/rules-error.ts` — `defineCodes("RULES", [24 codes])` + `RulesError extends SuaveplanError<RulesErrorCode>` + `isRulesError` guard + sibling `.test.ts` + sibling `.md`
|
||||
- [x] 2.2.3 `src/constants.ts` — PACKAGE_NAME, VERSION, DEFAULT_MAX_DEPTH, DEFAULT_EVENT_HISTORY, DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_ACTION_PARALLELISM + sibling `.md`
|
||||
|
||||
### 2.3 Engine + builder + context — ✅ landed 2026-05-11
|
||||
|
||||
- [x] 2.3.1 `src/engine/engine.ts` — `RuleEngine` with evaluate/evaluateAll/evaluateRuleset/check, depth limit (`RULES.ENGINE_DEPTH_EXCEEDED`), plugin registration (idempotent), AbortSignal (`RULES.ENGINE_ABORTED`), OTel histogram + counters + span; internal `compareValues` dispatcher over 33 operators + sibling .md + sibling test
|
||||
- [x] 2.3.2 `src/builder/builder.ts` — `RuleBuilder` (fluent + pending-operator combine), `FieldBuilder` (30 operator methods), `rule`/`condition`/`and`/`or`/`not`/`group` factories + sibling .md + sibling test
|
||||
- [x] 2.3.3 `src/context/context.ts` — `ContextBuilder` (immutable per call), `DefaultContextProvider` (injectable `Clock`, enhancer pipeline), `DefaultFieldResolver` (dot-path + `prop[N]`, strict mode → `RULES.FIELD_RESOLUTION_FAILED`), `createRequestContext`/`createScopedContext`, `getField`/`hasField`, Zod-validated options + sibling .md + sibling test
|
||||
- [x] 2.3.4 `src/otel/otel.ts` — lazy OTel instruments (`getInstruments` / `resetInstruments`) + sibling .md
|
||||
|
||||
### 2.4 Actions + events
|
||||
|
||||
- [ ] 2.4.1 `src/actions/actions.ts` — `Action`, `ActionRegistry`, `ActionExecutor`, `createAction`/`createActionRegistry`/`createActionExecutor`, `validateActionConfig`, `builtInHandlers` (log/set/delay/noop with `LoggerLike`)
|
||||
- [ ] 2.4.2 `src/events/event-bus.ts` — `EventBus`, `HookManager`, bounded history, priority hooks, `waitFor` with timeout + AbortSignal
|
||||
|
||||
### 2.5 Operators
|
||||
|
||||
- [ ] 2.5.1 `src/operators/registry.ts` — `OperatorRegistry`
|
||||
- [ ] 2.5.2 `src/operators/defaults.ts` — 30 built-in operators + `defaultOperatorRegistry` registration
|
||||
|
||||
### 2.6 Validators
|
||||
|
||||
- [ ] 2.6.1 `src/validators/schema/schema.ts` — `SchemaValidator` with formats, transforms, recursion, AbortSignal
|
||||
- [ ] 2.6.2 `src/validators/field/field.ts` — `FieldValidator`, `field()`, 12 preset chains
|
||||
- [ ] 2.6.3 `src/validators/custom/custom.ts` — `CustomValidator`, `ValidatorRegistry`, 17 built-ins + `composeValidators` + `defaultValidatorRegistry`
|
||||
|
||||
### 2.7 Serializer + optimizer
|
||||
|
||||
- [ ] 2.7.1 `src/serializer/serializer.ts` — full serialize/deserialize surface with function-placeholder strategy + Zod boundary
|
||||
- [ ] 2.7.2 `src/optimizer/optimizer.ts` — `optimizeRule`/`analyzeRule`/`estimateCost`/`findFields`/`hasAsyncComponents`/`countRules`
|
||||
|
||||
### 2.8 Barrel + benchmarks
|
||||
|
||||
- [ ] 2.8.1 `src/index.ts` — explicit named re-exports for every public symbol (no `export *`)
|
||||
- [ ] 2.8.2 `src/__benchmarks__/` — mitata benchmarks for engine.evaluate + serializer round-trip + optimizer
|
||||
|
||||
## 3. Implementation — @suaveplan/rules-hono
|
||||
|
||||
- [ ] 3.1 `packages/workflow/rules-hono/package.json` (peer `hono`, dep `@suaveplan/rules`)
|
||||
- [ ] 3.2 tsconfig + vite + vitest configs
|
||||
- [ ] 3.3 `src/middleware/rule.ts`, `validation.ts`, `access-control.ts`, `response-validation.ts`, `rate-limit-rule.ts`, `combine.ts`
|
||||
- [ ] 3.4 `src/engine/default.ts` — lazy `getDefaultEngine` + `resetDefaultEngine` under `./testing` subpath
|
||||
- [ ] 3.5 OTel surface for every middleware
|
||||
- [ ] 3.6 Barrel + subpath exports
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- [ ] 4.1 Unit tests per feature module covering every scenario (every `it()` has `{ timeout: 5000 }`)
|
||||
- [ ] 4.2 Spec-conformance tests (`*.spec-conformance.test.ts`) pinning type-level / OTel / RulesError-code-thrown / strict-mode scenarios
|
||||
- [ ] 4.3 Property-based tests on operators (commutative/associative) + optimizer + serializer round-trips (`fast-check`)
|
||||
- [ ] 4.4 Hono middleware tests via in-process `app.request(...)` (no mocks)
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [ ] 5.1 Sibling `.md` ≥200 words for every implementation `.ts` (Purpose / Features / Basic / Advanced / API Reference / Implementation Notes)
|
||||
- [ ] 5.2 Root `README.md` per package with Modules section linking every co-located `.md`
|
||||
- [ ] 5.3 Update root `CLAUDE.md` package listing
|
||||
- [ ] 5.4 Update `STATUS.md` (rules row + new rules-hono row)
|
||||
- [ ] 5.5 Update `SUMMARY.md` workflow section
|
||||
|
||||
## 6. 5-gate verification
|
||||
|
||||
- [ ] 6.1 `bun run check` — 0/0/0
|
||||
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/rules` — green
|
||||
- [ ] 6.3 `bunx turbo lint typecheck test --filter=@suaveplan/rules-hono` — green
|
||||
- [ ] 6.4 `bunx turbo test:coverage --filter=@suaveplan/rules` — 100/100/100/100
|
||||
- [ ] 6.5 `bunx turbo test:coverage --filter=@suaveplan/rules-hono` — 100/100/100/100
|
||||
- [ ] 6.6 `bun scripts/openspec-conformance.ts @suaveplan/rules` — exit 0
|
||||
- [ ] 6.7 `bun scripts/openspec-conformance.ts @suaveplan/rules-hono` — exit 0
|
||||
- [ ] 6.8 `bunx turbo build --filter=@suaveplan/{rules,rules-hono}` — green
|
||||
|
||||
## 7. Pre-archive 9-row checklist
|
||||
|
||||
- [ ] 7.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (type guards)
|
||||
- [ ] 7.2 Zod schemas wired at every declared boundary (RuleSchema, RulesetSchema, SerializedRuleSchema, SchemaSchema, ContextSchema, MiddlewareOptionsSchemas)
|
||||
- [ ] 7.3 Every code in `defineCodes("RULES", [...])` thrown by ≥1 production path
|
||||
- [ ] 7.4 Strict-mode behaviors tested both on and off (FieldResolver strict, abortEarly)
|
||||
- [ ] 7.5 OTel instruments emitted from production paths, asserted with in-memory `MeterProvider`
|
||||
- [ ] 7.6 Lifecycle dedup tested (`engine.use(plugin)` idempotent, `getDefaultEngine` returns identical reference)
|
||||
- [ ] 7.7 No retroactive tick — every box reflects work done in this change
|
||||
- [ ] 7.8 No `export *` in feature-module barrels (§6a) — `grep` clean
|
||||
- [ ] 7.9 No direct `vitest` / `bun:test` imports — `grep` clean
|
||||
|
||||
## Continuation pickup point (next session)
|
||||
|
||||
**State as of 2026-05-11 ~15:55:**
|
||||
|
||||
Sections 1, 2.1, 2.2 are complete. Section 2.3 (engine + builder + context) is the next atomic unit — start there.
|
||||
|
||||
**Recommended order:**
|
||||
|
||||
1. **2.3 engine + builder + context** — the engine consumes context + operators; builder produces rules. Implement context first (`FieldResolver` with dot-path/array-index, `ContextBuilder`, `DefaultContextProvider`, `createRequestContext` with injectable `Clock`), then builder, then engine (depth limit, AbortSignal, OTel histograms, plugin registration).
|
||||
2. **2.5 operators** — 30 built-ins are independent of engine internals beyond the `OperatorDefinition` shape (already in types). Can be implemented in parallel with 2.3 via subagent.
|
||||
3. **2.4 actions + events** — depend on context + operators. EventBus consumes injectable `Clock`.
|
||||
4. **2.6 validators** — three modules (schema/field/custom), each independent.
|
||||
5. **2.7 serializer + optimizer** — depend on the full `Rule` surface being stable.
|
||||
6. **2.8 barrel + benchmarks** — finalise `src/index.ts` to re-export every public symbol (no `export *`).
|
||||
7. **3.x rules-hono** — only after the rules core is green at 100% coverage.
|
||||
8. **5.x docs + 6.x gates + 7.x checklist + 8.x archive** — final sequence.
|
||||
|
||||
**Verification stub to land mid-stream once 2.3 is in:**
|
||||
|
||||
```bash
|
||||
cd /home/paul/web/genesis && bun install
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/rules
|
||||
bunx turbo test:coverage --filter=@suaveplan/rules
|
||||
```
|
||||
|
||||
Coverage must already be 100/100/100/100 on the foundation (types + errors + constants) before adding 2.3 on top.
|
||||
|
||||
## 8. Archive
|
||||
|
||||
- [ ] 8.1 Fold `specs/rules/spec.md` into `openspec/specs/rules/spec.md`
|
||||
- [ ] 8.2 Fold `specs/rules-hono/spec.md` into `openspec/specs/rules-hono/spec.md`
|
||||
- [ ] 8.3 Move `openspec/changes/enterprise-rules-migration/` → `openspec/archive/<yyyy-mm-dd>-enterprise-rules-migration/`
|
||||
- [ ] 8.4 `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [ ] 8.5 `bun scripts/generate-missing.ts`
|
||||
- [ ] 8.6 `bunx markdownlint-cli2 --fix`
|
||||
Reference in New Issue
Block a user