ALL-1874 PR-1: Backend — extend rule engine with new matcher and transform types
completedAgent: will-engineer
Priority: 3
Branch:
wilbo/all-1874-rule-engine-extensionsPR: #12363
Linear: ALL-1874
## Context
Linear issue ALL-1874. The existing rule engine (ruleEngine.ts + resolutionRuleService.ts) supports matchers: regex, equals, jsonPath — and transforms: padLeft, replace, trim, setField. This PR extends both sides with the new types required by the feature.
## Key files
- `domains/identity/src/services/staging/ruleEngine.ts` — MatcherSchema (discriminatedUnion), TransformSchema (discriminatedUnion), matcherFires(), applyTransform()
- `domains/identity/src/services/staging/resolutionRuleService.ts` — duplicate Zod schemas for CRUD validation (CreateResolutionRuleSchema, UpdateResolutionRuleSchema); MUST stay in sync with ruleEngine.ts
- `domains/identity/src/services/staging/ruleEngine.test.ts` — existing tests to extend
- `domains/identity/src/services/staging/resolutionRuleService.test.ts` — existing tests to extend
## Changes required
### New matcher types (add to MatcherSchema discriminatedUnion in both files)
- `contains` — { kind: "contains", field: string, value: string }
- `startsWith` — { kind: "startsWith", field: string, value: string }
- `endsWith` — { kind: "endsWith", field: string, value: string }
- `range` — { kind: "range", field: string, min: number, max: number } — numeric range (inclusive)
- `oneOf` — { kind: "oneOf", field: string, values: unknown[] } — value in list
### New transform types (add to TransformSchema discriminatedUnion in both files)
- `uppercase` — { kind: "uppercase", field: string }
- `lowercase` — { kind: "lowercase", field: string }
- `formatPhone` — { kind: "formatPhone", field: string, defaultCountry?: string } — normalize to E.164; use `libphonenumber-js` if available, else simple strip+prefix
- `formatPostalCode` — { kind: "formatPostalCode", field: string, padLength?: number } — pad left with zeros to padLength (default 5)
- `concatenate` — { kind: "concatenate", field: string, sources: string[], separator?: string } — combine multiple fields into target field
- `mapValue` — { kind: "mapValue", field: string, map: Record<string, string>, fallback?: string } — lookup table replacement
### matcherFires() in ruleEngine.ts
- Add cases for contains, startsWith, endsWith, range, oneOf
- For range: coerce field value to number via parseFloat; non-numeric → no-match
### applyTransform() in ruleEngine.ts
- Add cases for all 6 new transform kinds
- formatPhone: use libphonenumber-js parsePhoneNumber / formatNumber if importable; if dep unavailable stub as strip-then-prefix
- All other cases are pure string operations
## Acceptance test
- `pnpm --filter identity test ruleEngine` passes with coverage of all new matcher + transform kinds
- `pnpm --filter identity test resolutionRuleService` passes (Zod validation accepts new kinds, rejects unknown)
- TypeScript exhaustiveness guard (never) still compiles without errors
## Notes
- Do NOT change the Prisma schema — transform is still a single JSON object in this PR
- Do NOT touch GraphQL SDL or resolvers — pure service layer
- Rule chaining (multi-transform array) is a separate PR (PR-3)
- The graphql `transform: JSON!` field stays as-is; SDL changes come with PR-3
Event Timeline
created
status_change
queued → in_progress
failed
lease expired — re-queued for retry
in_progress → queued
status_change
queued → completed