@cullet/erp-core 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/KIT_CONTEXT.md +7 -1
  2. package/dist/abac/index.d.cts +2 -2
  3. package/dist/abac/index.d.ts +2 -2
  4. package/dist/aggregate-version.cjs +14 -0
  5. package/dist/aggregate-version.cjs.map +1 -0
  6. package/dist/aggregate-version.js +9 -0
  7. package/dist/aggregate-version.js.map +1 -0
  8. package/dist/app-error.cjs +21 -6
  9. package/dist/app-error.cjs.map +1 -1
  10. package/dist/app-error.js +21 -6
  11. package/dist/app-error.js.map +1 -1
  12. package/dist/application/index.cjs +8 -4
  13. package/dist/application/index.d.cts +2 -2
  14. package/dist/application/index.d.ts +2 -2
  15. package/dist/application/index.js +1 -2
  16. package/dist/authorization-error.cjs +67 -17
  17. package/dist/authorization-error.cjs.map +1 -1
  18. package/dist/authorization-error.d.cts +6 -2
  19. package/dist/authorization-error.d.ts +6 -2
  20. package/dist/authorization-error.js +50 -18
  21. package/dist/authorization-error.js.map +1 -1
  22. package/dist/authorizer.port.d.cts +18 -3
  23. package/dist/authorizer.port.d.ts +18 -3
  24. package/dist/composite-authorizer.d.cts +63 -10
  25. package/dist/composite-authorizer.d.ts +63 -10
  26. package/dist/condition-evaluator.cjs +117 -172
  27. package/dist/condition-evaluator.cjs.map +1 -1
  28. package/dist/condition-evaluator.js +118 -167
  29. package/dist/condition-evaluator.js.map +1 -1
  30. package/dist/core-config.d.cts +53 -6
  31. package/dist/core-config.d.ts +53 -6
  32. package/dist/decorate.cjs +14 -0
  33. package/dist/decorate.js +9 -0
  34. package/dist/domain/index.cjs +107 -2
  35. package/dist/domain/index.cjs.map +1 -0
  36. package/dist/domain/index.d.cts +25 -1
  37. package/dist/domain/index.d.ts +25 -1
  38. package/dist/domain/index.js +99 -3
  39. package/dist/domain/index.js.map +1 -0
  40. package/dist/domain-event-contracts.cjs +12 -8
  41. package/dist/domain-event-contracts.cjs.map +1 -1
  42. package/dist/domain-event-contracts.d.cts +29 -4
  43. package/dist/domain-event-contracts.d.ts +29 -4
  44. package/dist/domain-event-contracts.js +11 -7
  45. package/dist/domain-event-contracts.js.map +1 -1
  46. package/dist/domain-exception.cjs +2 -1
  47. package/dist/domain-exception.cjs.map +1 -1
  48. package/dist/domain-exception.js +2 -1
  49. package/dist/domain-exception.js.map +1 -1
  50. package/dist/errors/index.cjs +3 -2
  51. package/dist/errors/index.d.cts +1 -1
  52. package/dist/errors/index.d.ts +1 -1
  53. package/dist/errors/index.js +3 -2
  54. package/dist/exceptions/index.cjs +2 -2
  55. package/dist/exceptions/index.d.cts +1 -2
  56. package/dist/exceptions/index.d.ts +1 -2
  57. package/dist/exceptions/index.js +2 -2
  58. package/dist/gate-engine-registry.cjs.map +1 -1
  59. package/dist/gate-engine-registry.d.cts +1 -1
  60. package/dist/gate-engine-registry.d.ts +1 -1
  61. package/dist/gate-engine-registry.js.map +1 -1
  62. package/dist/gate-v1-payload.schema.cjs +11 -6
  63. package/dist/gate-v1-payload.schema.cjs.map +1 -1
  64. package/dist/gate-v1-payload.schema.js +11 -6
  65. package/dist/gate-v1-payload.schema.js.map +1 -1
  66. package/dist/hashing.cjs +2 -44
  67. package/dist/hashing.cjs.map +1 -1
  68. package/dist/hashing.js +3 -39
  69. package/dist/hashing.js.map +1 -1
  70. package/dist/immutable.cjs +25 -12
  71. package/dist/immutable.cjs.map +1 -1
  72. package/dist/immutable.js +24 -11
  73. package/dist/immutable.js.map +1 -1
  74. package/dist/index.cjs +15 -11
  75. package/dist/index.cjs.map +1 -1
  76. package/dist/index.d.cts +9 -10
  77. package/dist/index.d.ts +9 -10
  78. package/dist/index.js +8 -9
  79. package/dist/index.js.map +1 -1
  80. package/dist/invalid-state-transition-exception.cjs +6 -6
  81. package/dist/invalid-state-transition-exception.cjs.map +1 -1
  82. package/dist/invalid-state-transition-exception.js +6 -6
  83. package/dist/invalid-state-transition-exception.js.map +1 -1
  84. package/dist/invariant-violation-exception.cjs +2 -2
  85. package/dist/invariant-violation-exception.cjs.map +1 -1
  86. package/dist/invariant-violation-exception.js +2 -2
  87. package/dist/invariant-violation-exception.js.map +1 -1
  88. package/dist/not-found-error.cjs +6 -4
  89. package/dist/not-found-error.cjs.map +1 -1
  90. package/dist/not-found-error.js +6 -4
  91. package/dist/not-found-error.js.map +1 -1
  92. package/dist/outcome.cjs +5 -5
  93. package/dist/outcome.cjs.map +1 -1
  94. package/dist/outcome.js +5 -5
  95. package/dist/outcome.js.map +1 -1
  96. package/dist/parse-gate-payload.d.cts +1 -1
  97. package/dist/parse-gate-payload.d.ts +1 -1
  98. package/dist/path.d.cts +2 -2
  99. package/dist/path.d.ts +2 -2
  100. package/dist/plugin.cjs +23 -13
  101. package/dist/plugin.cjs.map +1 -1
  102. package/dist/plugin.d.cts +22 -8
  103. package/dist/plugin.d.ts +22 -8
  104. package/dist/plugin.js +23 -13
  105. package/dist/plugin.js.map +1 -1
  106. package/dist/policies/engines/index.d.cts +1 -1
  107. package/dist/policies/engines/index.d.ts +1 -1
  108. package/dist/policies/engines/v1/gate/index.d.cts +3 -13
  109. package/dist/policies/engines/v1/gate/index.d.ts +3 -13
  110. package/dist/policies/index.d.cts +1 -1
  111. package/dist/policies/index.d.ts +1 -1
  112. package/dist/policy-bridge.cjs +30 -2
  113. package/dist/policy-bridge.cjs.map +1 -1
  114. package/dist/policy-bridge.d.cts +18 -0
  115. package/dist/policy-bridge.d.ts +18 -0
  116. package/dist/policy-bridge.js +30 -2
  117. package/dist/policy-bridge.js.map +1 -1
  118. package/dist/policy-service.cjs +41 -46
  119. package/dist/policy-service.cjs.map +1 -1
  120. package/dist/policy-service.d.cts +72 -9
  121. package/dist/policy-service.d.ts +72 -9
  122. package/dist/policy-service.js +43 -48
  123. package/dist/policy-service.js.map +1 -1
  124. package/dist/requested-by.cjs +4 -4
  125. package/dist/requested-by.cjs.map +1 -1
  126. package/dist/requested-by.js +2 -2
  127. package/dist/requested-by.js.map +1 -1
  128. package/dist/result.cjs +29 -1
  129. package/dist/result.cjs.map +1 -1
  130. package/dist/result.d.cts +12 -0
  131. package/dist/result.d.ts +12 -0
  132. package/dist/result.js +29 -1
  133. package/dist/result.js.map +1 -1
  134. package/dist/rule.cjs +94 -24
  135. package/dist/rule.cjs.map +1 -1
  136. package/dist/rule.js +94 -24
  137. package/dist/rule.js.map +1 -1
  138. package/dist/ruleset-registry.cjs +19 -0
  139. package/dist/ruleset-registry.cjs.map +1 -1
  140. package/dist/ruleset-registry.js +19 -0
  141. package/dist/ruleset-registry.js.map +1 -1
  142. package/dist/stable-stringify.cjs +87 -0
  143. package/dist/stable-stringify.cjs.map +1 -0
  144. package/dist/stable-stringify.js +76 -0
  145. package/dist/stable-stringify.js.map +1 -0
  146. package/dist/temporal-snapshot.d.cts +87 -0
  147. package/dist/temporal-snapshot.d.ts +87 -0
  148. package/dist/temporal-use-case.cjs +174 -16
  149. package/dist/temporal-use-case.cjs.map +1 -1
  150. package/dist/temporal-use-case.d.cts +82 -44
  151. package/dist/temporal-use-case.d.ts +82 -44
  152. package/dist/temporal-use-case.js +167 -15
  153. package/dist/temporal-use-case.js.map +1 -1
  154. package/dist/unexpected-error.cjs +4 -2
  155. package/dist/unexpected-error.cjs.map +1 -1
  156. package/dist/unexpected-error.js +4 -2
  157. package/dist/unexpected-error.js.map +1 -1
  158. package/dist/uuid-identifier.cjs +141 -8
  159. package/dist/uuid-identifier.cjs.map +1 -1
  160. package/dist/uuid-identifier.d.cts +26 -7
  161. package/dist/uuid-identifier.d.ts +26 -7
  162. package/dist/uuid-identifier.js +135 -8
  163. package/dist/uuid-identifier.js.map +1 -1
  164. package/dist/uuid.cjs +23 -0
  165. package/dist/uuid.cjs.map +1 -0
  166. package/dist/uuid.js +18 -0
  167. package/dist/uuid.js.map +1 -0
  168. package/dist/validation-code.cjs +9 -0
  169. package/dist/validation-code.cjs.map +1 -1
  170. package/dist/validation-code.d.cts +3 -0
  171. package/dist/validation-code.d.ts +3 -0
  172. package/dist/validation-code.js +9 -0
  173. package/dist/validation-code.js.map +1 -1
  174. package/dist/validation-error.cjs +42 -67
  175. package/dist/validation-error.cjs.map +1 -1
  176. package/dist/validation-error.d.cts +29 -8
  177. package/dist/validation-error.d.ts +29 -8
  178. package/dist/validation-error.js +41 -66
  179. package/dist/validation-error.js.map +1 -1
  180. package/dist/validation-exception.cjs +17 -6
  181. package/dist/validation-exception.cjs.map +1 -1
  182. package/dist/validation-exception.d.cts +33 -9
  183. package/dist/validation-exception.d.ts +33 -9
  184. package/dist/validation-exception.js +17 -6
  185. package/dist/validation-exception.js.map +1 -1
  186. package/dist/validation-field.cjs +3 -0
  187. package/dist/validation-field.cjs.map +1 -1
  188. package/dist/validation-field.d.cts +1 -0
  189. package/dist/validation-field.d.ts +1 -0
  190. package/dist/validation-field.js +3 -0
  191. package/dist/validation-field.js.map +1 -1
  192. package/dist/value-object-ruleset.contracts.d.cts +10 -0
  193. package/dist/value-object-ruleset.contracts.d.ts +10 -0
  194. package/dist/value-object.cjs +23 -4
  195. package/dist/value-object.cjs.map +1 -1
  196. package/dist/value-object.d.cts +25 -1
  197. package/dist/value-object.d.ts +25 -1
  198. package/dist/value-object.js +23 -4
  199. package/dist/value-object.js.map +1 -1
  200. package/dist/version.d.cts +27 -0
  201. package/dist/version.d.ts +27 -0
  202. package/dist/versioning/index.d.cts +2 -2
  203. package/dist/versioning/index.d.ts +2 -2
  204. package/meta.json +5 -2
  205. package/package.json +1 -1
  206. package/src/core/abac/authorizer.ts +60 -10
  207. package/src/core/abac/domain/policy-set.ts +52 -5
  208. package/src/core/abac/domain/rule.ts +28 -16
  209. package/src/core/abac/index.ts +7 -1
  210. package/src/core/application/commands/command.ts +14 -1
  211. package/src/core/application/commands/requested-by.ts +13 -5
  212. package/src/core/application/index.ts +2 -1
  213. package/src/core/application/policy-error-mapper.ts +6 -0
  214. package/src/core/application/ports/index.ts +7 -0
  215. package/src/core/application/ports/temporal-repository.port.ts +35 -2
  216. package/src/core/application/queries/index.ts +1 -1
  217. package/src/core/application/queries/query.ts +25 -3
  218. package/src/core/application/temporal/temporal-use-case.ts +31 -4
  219. package/src/core/application/use-case.ts +45 -8
  220. package/src/core/config/core-config.ts +46 -25
  221. package/src/core/config/index.ts +1 -0
  222. package/src/core/config/policy-reporter.ts +32 -1
  223. package/src/core/domain/entity.ts +51 -8
  224. package/src/core/domain/rulesets/entity-ruleset.contracts.ts +0 -2
  225. package/src/core/domain/rulesets/ruleset-registry.ts +24 -0
  226. package/src/core/domain/rulesets/value-object-ruleset.contracts.ts +1 -7
  227. package/src/core/domain/temporal/half-open-interval.ts +34 -0
  228. package/src/core/domain/temporal/temporal-snapshot.ts +13 -2
  229. package/src/core/domain/temporal/transaction-time.ts +19 -15
  230. package/src/core/domain/temporal/valid-time.ts +20 -15
  231. package/src/core/domain/uuid-identifier.ts +6 -11
  232. package/src/core/domain/value-object.ts +29 -3
  233. package/src/core/errors/authentication-error.ts +5 -31
  234. package/src/core/errors/authorization-error.ts +12 -20
  235. package/src/core/errors/business-rule-violation-error.ts +4 -1
  236. package/src/core/errors/idempotency-error.ts +5 -2
  237. package/src/core/errors/index.ts +4 -0
  238. package/src/core/errors/integration-error.ts +4 -24
  239. package/src/core/errors/legacy-incompatible-error.ts +1 -0
  240. package/src/core/errors/not-found-error.ts +4 -1
  241. package/src/core/errors/temporal-error.ts +22 -20
  242. package/src/core/errors/unexpected-error.ts +5 -1
  243. package/src/core/errors/utils/factory-helpers.ts +70 -0
  244. package/src/core/errors/utils/index.ts +5 -0
  245. package/src/core/errors/utils/json-safe.ts +35 -12
  246. package/src/core/errors/validation-error.ts +4 -1
  247. package/src/core/exceptions/business-rule-violation-exception.ts +2 -1
  248. package/src/core/exceptions/domain-exception.ts +9 -1
  249. package/src/core/exceptions/entity-not-found-exception.ts +5 -1
  250. package/src/core/exceptions/invalid-state-transition-exception.ts +5 -2
  251. package/src/core/exceptions/invariant-violation-exception.ts +2 -2
  252. package/src/core/exceptions/validation-code.ts +14 -0
  253. package/src/core/exceptions/validation-exception.ts +44 -5
  254. package/src/core/exceptions/validation-field.ts +11 -1
  255. package/src/core/plugins/plugin.ts +25 -15
  256. package/src/core/plugins/types.ts +4 -2
  257. package/src/core/policies/asof/asof.ts +12 -0
  258. package/src/core/policies/catalog/policy-catalog-entry.ts +3 -1
  259. package/src/core/policies/catalog/policy-catalog.ts +5 -1
  260. package/src/core/policies/context/context-builder.ts +20 -0
  261. package/src/core/policies/context/context-resolver.ts +5 -0
  262. package/src/core/policies/context/context-seed.ts +11 -0
  263. package/src/core/policies/defs/in-memory-policy-definition-repo.ts +0 -2
  264. package/src/core/policies/engines/parse-gate-payload.ts +9 -0
  265. package/src/core/policies/engines/v1/condition-date-operands.ts +112 -0
  266. package/src/core/policies/engines/v1/condition-evaluator.ts +120 -291
  267. package/src/core/policies/engines/v1/condition-schema.ts +23 -19
  268. package/src/core/policies/engines/v1/condition-types.ts +18 -11
  269. package/src/core/policies/index.ts +4 -6
  270. package/src/core/policies/service/policy-service.ts +46 -35
  271. package/src/core/policies/utils/hash.ts +5 -69
  272. package/src/core/policies/utils/result.ts +1 -1
  273. package/src/core/rbac/access-request.ts +12 -2
  274. package/src/core/rbac/authorizer.ts +8 -1
  275. package/src/core/rbac/domain/role.ts +20 -0
  276. package/src/core/rbac/domain/scope.ts +6 -1
  277. package/src/core/result/index.ts +5 -0
  278. package/src/core/result/outcome.ts +5 -7
  279. package/src/core/result/result.ts +34 -1
  280. package/src/core/shared/hashing.ts +6 -57
  281. package/src/core/shared/immutable.ts +22 -4
  282. package/src/core/shared/stable-stringify.ts +144 -0
  283. package/src/core/shared/uuid.ts +16 -0
  284. package/src/core/versioning/domain-event-contracts.ts +43 -15
  285. package/src/core/versioning/index.ts +1 -0
  286. package/src/core/versioning/version.ts +30 -1
  287. package/src/domain/index.ts +5 -0
  288. package/src/examples/rulesets/entity/order-creation-rules-v1.ts +1 -1
  289. package/src/examples/rulesets/entity/order-invariants-v1.ts +2 -4
  290. package/src/examples/rulesets/entity/order-invariants-v2.ts +2 -4
  291. package/src/examples/rulesets/value-object/cpf-rules-v1.ts +2 -4
  292. package/src/examples/rulesets/value-object/cpf-rules-v2.ts +2 -4
  293. package/src/examples/rulesets/value-object/person-name-rules-v1.ts +2 -4
  294. package/src/examples/rulesets/value-object/person-name-rules-v2.ts +2 -4
  295. package/src/result/index.ts +7 -2
  296. package/src/version.ts +1 -1
  297. package/dist/domain-exception.d.cts +0 -7
  298. package/dist/domain-exception.d.ts +0 -7
  299. package/dist/entity.cjs +0 -126
  300. package/dist/entity.cjs.map +0 -1
  301. package/dist/entity.js +0 -115
  302. package/dist/entity.js.map +0 -1
  303. package/dist/use-case.cjs +0 -96
  304. package/dist/use-case.cjs.map +0 -1
  305. package/dist/use-case.js +0 -91
  306. package/dist/use-case.js.map +0 -1
@@ -30,18 +30,23 @@ function sanitizeValue(value: unknown, seen: WeakSet<object>): JsonSafeValue {
30
30
 
31
31
  const type = typeof value;
32
32
 
33
- // Primitives
34
- if (type === "string" || type === "number" || type === "boolean") {
33
+ // Primitives (non-finite numbers are not JSON-representable — placeholder)
34
+ if (type === "number") {
35
+ return Number.isFinite(value)
36
+ ? (value as number)
37
+ : NON_SERIALIZABLE_PLACEHOLDER;
38
+ }
39
+ if (type === "string" || type === "boolean") {
35
40
  return value as JsonSafeValue;
36
41
  }
37
42
 
43
+ // `undefined` only reaches here as an array item: object properties with
44
+ // undefined values are omitted by the plain-object branch below. Both
45
+ // mirror `JSON.stringify` semantics.
46
+ if (value === undefined) return null;
47
+
38
48
  // Non-serializable primitives
39
- if (
40
- type === "bigint" ||
41
- type === "function" ||
42
- type === "symbol" ||
43
- value === undefined
44
- ) {
49
+ if (type === "bigint" || type === "function" || type === "symbol") {
45
50
  return NON_SERIALIZABLE_PLACEHOLDER;
46
51
  }
47
52
 
@@ -69,7 +74,22 @@ function sanitizeValue(value: unknown, seen: WeakSet<object>): JsonSafeValue {
69
74
  seen.add(value);
70
75
  const result: Record<string, JsonSafeValue> = {};
71
76
  for (const [key, val] of Object.entries(value)) {
72
- result[key] = sanitizeValue(val, seen);
77
+ if (val === undefined) continue;
78
+ const sanitized = sanitizeValue(val, seen);
79
+ if (key === "__proto__") {
80
+ // Plain `result.__proto__ = x` would reparent `result` instead of
81
+ // creating an own data property, silently dropping the key. Define
82
+ // it explicitly so `__proto__` survives as data — matching how
83
+ // JSON.stringify/JSON.parse round-trip it.
84
+ Object.defineProperty(result, key, {
85
+ value: sanitized,
86
+ enumerable: true,
87
+ writable: true,
88
+ configurable: true,
89
+ });
90
+ } else {
91
+ result[key] = sanitized;
92
+ }
73
93
  }
74
94
  seen.delete(value);
75
95
  return result;
@@ -82,11 +102,14 @@ function sanitizeValue(value: unknown, seen: WeakSet<object>): JsonSafeValue {
82
102
  /**
83
103
  * Ensures metadata is JSON-serializable.
84
104
  *
85
- * **Allowed:** string, number, boolean, null, arrays, and plain objects.
105
+ * **Allowed:** finite number, string, boolean, null, arrays, and plain objects.
106
+ *
107
+ * **Dropped (mirroring `JSON.stringify`):** object properties whose value is
108
+ * `undefined` (the key is omitted); an `undefined` array item becomes `null`.
86
109
  *
87
110
  * **Converted to placeholder:** Date, BigInt, class instances, functions,
88
- * symbols, undefined, and circular references (which would otherwise make
89
- * `JSON.stringify` throw).
111
+ * symbols, non-finite numbers (`NaN`/`Infinity`), and circular references
112
+ * (which would otherwise make `JSON.stringify` throw).
90
113
  *
91
114
  * @throws {TypeError} If `input` is not a plain object at the root level.
92
115
  */
@@ -38,14 +38,17 @@ class ValidationError extends AppError {
38
38
  field: field.value,
39
39
  validationCode: code.value,
40
40
  };
41
+ // `field`/`validationCode` spread last so caller metadata is merged
42
+ // over them without ever overwriting them by accident.
41
43
  const mergedMetadata = options?.metadata
42
- ? { ...baseMetadata, ...options.metadata }
44
+ ? { ...options.metadata, ...baseMetadata }
43
45
  : baseMetadata;
44
46
 
45
47
  super(message, ErrorCodes.validation, {
46
48
  ...options,
47
49
  metadata: mergedMetadata,
48
50
  });
51
+ Object.freeze(this);
49
52
  }
50
53
  }
51
54
 
@@ -4,8 +4,9 @@ class BusinessRuleViolationException extends DomainException {
4
4
  constructor(
5
5
  public readonly rule: string,
6
6
  message: string,
7
+ options?: { cause?: unknown },
7
8
  ) {
8
- super(message);
9
+ super(message, options);
9
10
  }
10
11
  }
11
12
 
@@ -1,6 +1,14 @@
1
1
  abstract class DomainException extends Error {
2
- constructor(message: string) {
2
+ // ES2020's `Error` lib type does not declare `cause`; mirror the sibling
3
+ // `AppError` and carry it explicitly so a domain exception can wrap the
4
+ // lower-level failure that triggered it. Assigned rather than passed to
5
+ // `super` to stay independent of the ES2022 `Error(message, { cause })`
6
+ // overload the build target doesn't guarantee.
7
+ public readonly cause?: unknown;
8
+
9
+ constructor(message: string, options?: { cause?: unknown }) {
3
10
  super(message);
11
+ this.cause = options?.cause;
4
12
  Object.setPrototypeOf(this, new.target.prototype);
5
13
  this.name = new.target.name;
6
14
  }
@@ -4,8 +4,12 @@ class EntityNotFoundException extends DomainException {
4
4
  constructor(
5
5
  public readonly entityName: string,
6
6
  public readonly identifier: string,
7
+ options?: { cause?: unknown },
7
8
  ) {
8
- super(`${entityName} with identifier ${identifier} was not found.`);
9
+ super(
10
+ `${entityName} with identifier ${identifier} was not found.`,
11
+ options,
12
+ );
9
13
  }
10
14
  }
11
15
 
@@ -6,9 +6,12 @@ class InvalidStateTransitionException<
6
6
  constructor(
7
7
  public readonly from: TState,
8
8
  public readonly to: TState,
9
- message: string,
9
+ message?: string,
10
+ options?: { cause?: unknown },
10
11
  ) {
11
- super(message);
12
+ // Compose a sensible default from `from`/`to` when no message is given,
13
+ // mirroring how EntityNotFoundException builds its own message.
14
+ super(message ?? `Cannot transition from ${from} to ${to}.`, options);
12
15
  }
13
16
  }
14
17
 
@@ -1,8 +1,8 @@
1
1
  import { DomainException } from "./domain-exception.js";
2
2
 
3
3
  class InvariantViolationException extends DomainException {
4
- constructor(message: string) {
5
- super(message);
4
+ constructor(message: string, options?: { cause?: unknown }) {
5
+ super(message, options);
6
6
  }
7
7
  }
8
8
 
@@ -13,6 +13,20 @@ class ValidationCode {
13
13
  return new ValidationCode(value);
14
14
  }
15
15
 
16
+ public toString(): string {
17
+ return this.value;
18
+ }
19
+
20
+ public equals(other: ValidationCode): boolean {
21
+ return this.value === other.value;
22
+ }
23
+
24
+ // Serialize to the bare code string, so JSON.stringify of a violation
25
+ // yields "blank" rather than { "value": "blank" }. Mirrors ValidationField.
26
+ public toJSON(): string {
27
+ return this.value;
28
+ }
29
+
16
30
  // Common, domain-friendly codes
17
31
  public static readonly BLANK = new ValidationCode("blank");
18
32
  public static readonly REQUIRED = new ValidationCode("required");
@@ -1,4 +1,5 @@
1
1
  import { DomainException } from "./domain-exception.js";
2
+ import { InvariantViolationException } from "./invariant-violation-exception.js";
2
3
  import { ValidationCode } from "./validation-code.js";
3
4
  import { ValidationField } from "./validation-field.js";
4
5
 
@@ -8,27 +9,65 @@ export interface ValidationViolation {
8
9
  readonly message: string;
9
10
  }
10
11
 
12
+ // Extensible base for a single-field validation failure. Prefer throwing the
13
+ // concrete InvalidValueException below so callers can `instanceof` the common
14
+ // case; extend this directly only when you need a distinct validation subtype.
11
15
  class ValidationException extends DomainException {
12
16
  constructor(
13
17
  public readonly field: ValidationField,
14
18
  public readonly code: ValidationCode,
15
19
  message: string,
20
+ options?: { cause?: unknown },
16
21
  ) {
17
- super(message);
22
+ super(message, options);
18
23
  }
19
24
  }
20
25
 
21
26
  // Carries multiple validation violations from a single operation (e.g. bulk validation).
22
27
  // Use when you need to report all field errors at once rather than failing on the first.
23
28
  class MultipleValidationException extends DomainException {
24
- constructor(public readonly violations: readonly ValidationViolation[]) {
25
- super(violations.map((v) => v.message).join("; "));
29
+ constructor(
30
+ public readonly violations: readonly ValidationViolation[],
31
+ options?: { cause?: unknown },
32
+ ) {
33
+ // An aggregate with no violations is a caller bug: it produces a
34
+ // messageless, contentless exception. Fail fast as a broken invariant.
35
+ if (violations.length === 0) {
36
+ throw new InvariantViolationException(
37
+ "MultipleValidationException requires at least one violation.",
38
+ );
39
+ }
40
+ super(violations.map((v) => v.message).join("; "), options);
41
+ }
42
+
43
+ // Number of accumulated violations (always >= 1).
44
+ get count(): number {
45
+ return this.violations.length;
46
+ }
47
+
48
+ // The first accumulated violation.
49
+ get first(): ValidationViolation {
50
+ return this.violations[0];
51
+ }
52
+
53
+ // Violations targeting the given field.
54
+ byField(field: ValidationField): readonly ValidationViolation[] {
55
+ return this.violations.filter((v) => v.field.equals(field));
26
56
  }
27
57
  }
28
58
 
59
+ // The canonical concrete validation exception, thrown across the domain
60
+ // (rbac/abac/policies/commands) when a single input value is invalid. Its
61
+ // identity is exactly that: the named, catchable "a value was invalid" case,
62
+ // versus ValidationException as the open base.
29
63
  class InvalidValueException extends ValidationException {
30
- constructor(field: ValidationField, code: ValidationCode, message: string) {
31
- super(field, code, message);
64
+ constructor(
65
+ field: ValidationField,
66
+ code: ValidationCode,
67
+ message: string,
68
+ options?: { cause?: unknown },
69
+ ) {
70
+ super(field, code, message, options);
32
71
  }
33
72
  }
34
73
 
@@ -14,7 +14,11 @@ class ValidationField {
14
14
  return new ValidationField(value);
15
15
  }
16
16
 
17
- // Creates a nested field path: parent.nested('child') -> 'parent.child'
17
+ // Creates a nested field path: parent.nested('child') -> 'parent.child'.
18
+ // The separator is a literal '.' and segments are NOT escaped, so
19
+ // of('a.b') and of('a').nested('b') collapse to the same path and compare
20
+ // equal — a field name that itself contains '.' is indistinguishable from
21
+ // a nested path. Keep segment names dot-free if that distinction matters.
18
22
  public nested(child: string | ValidationField): ValidationField {
19
23
  const childValue =
20
24
  child instanceof ValidationField ? child.value : child;
@@ -25,6 +29,12 @@ class ValidationField {
25
29
  return this.value;
26
30
  }
27
31
 
32
+ // Serialize to the bare field path, so JSON.stringify of a violation yields
33
+ // "email" rather than { "value": "email" }. Mirrors ValidationCode.
34
+ public toJSON(): string {
35
+ return this.value;
36
+ }
37
+
28
38
  public equals(other: ValidationField): boolean {
29
39
  return this.value === other.value;
30
40
  }
@@ -9,11 +9,23 @@ import {
9
9
  * Registry that holds a set of plugins implementing a shared {@link PluginContract}
10
10
  * and resolves which of them answer a given extension point.
11
11
  *
12
- * Plugins are kept sorted by priority (descending). For a given method,
13
- * {@link invoke} either delegates to the highest-priority enabled plugin
14
- * (`'first'`) or folds every enabled plugin into a single value (`'pipeline'`).
15
- * When no plugin is enabled, the supplied `fallback` runs instead, so the host
16
- * always has defined behaviour even with an empty registry.
12
+ * Plugins are kept sorted by priority (descending); ties keep registration
13
+ * order (the sort is stable). For a given method, {@link invoke} either
14
+ * delegates to the highest-priority enabled plugin (`'first'`) or folds every
15
+ * enabled plugin into a single value (`'pipeline'`). When no plugin is enabled,
16
+ * the supplied `fallback` runs instead, so the host always has defined
17
+ * behaviour even with an empty registry.
18
+ *
19
+ * `name` is a unique key: registering a plugin whose name already exists
20
+ * replaces the previous one, so register/enable/disable/unregister all address
21
+ * the same single plugin.
22
+ *
23
+ * Plugins run arbitrary host code and are trusted: {@link invoke} does NOT wrap
24
+ * them in try/catch. A throwing plugin propagates to the caller (and, in
25
+ * `'pipeline'` mode, discards the accumulated result). Error handling, logging
26
+ * and retries are the host's responsibility by design — the registry's only
27
+ * defined-behaviour guarantee is `fallback` for an empty registry. Every plugin
28
+ * is expected to implement the full contract `P` (the types enforce it).
17
29
  *
18
30
  * @typeParam P - The plugin contract this manager coordinates.
19
31
  */
@@ -21,8 +33,10 @@ class PluginManager<P extends PluginContract> {
21
33
  /** Internal list, kept sorted by priority (desc). */
22
34
  private plugins: (P & BasePlugin)[] = [];
23
35
 
24
- /** Registers one or more plugins and re-sorts by priority. */
36
+ /** Registers one or more plugins (replacing any with the same name) and re-sorts by priority. */
25
37
  public register(...plugins: (P & BasePlugin)[]): void {
38
+ const incoming = new Set(plugins.map((p) => p.name));
39
+ this.plugins = this.plugins.filter((p) => !incoming.has(p.name));
26
40
  this.plugins.push(...plugins);
27
41
  this.reorder();
28
42
  }
@@ -63,17 +77,14 @@ class PluginManager<P extends PluginContract> {
63
77
  opts: InvokeOptions<P, K>,
64
78
  ): ReturnType<P[K]> {
65
79
  const { mode = "first", fallback, reducer } = opts;
66
- const enabled = this.list().filter((p) => method in p) as Array<
67
- P & BasePlugin
68
- >;
80
+ const enabled = this.list();
69
81
 
70
82
  if (enabled.length === 0) {
71
- return (fallback as P[K])(...args);
83
+ return fallback(...args);
72
84
  }
73
85
 
74
86
  if (mode === "first") {
75
- const fn = enabled[0][method] as P[K];
76
- return fn(...args);
87
+ return enabled[0][method](...args);
77
88
  }
78
89
 
79
90
  const defaultReducer: PipelineReducer<ReturnType<P[K]>, P> = (
@@ -82,10 +93,9 @@ class PluginManager<P extends PluginContract> {
82
93
  ) => curr;
83
94
  const red = reducer ?? defaultReducer;
84
95
 
85
- let acc: ReturnType<P[K]> = enabled[0][method]!(...args);
96
+ let acc: ReturnType<P[K]> = enabled[0][method](...args);
86
97
  enabled.slice(1).forEach((plugin, idx) => {
87
- const res = plugin[method]!(...args);
88
- acc = red(acc, res, plugin, idx + 1);
98
+ acc = red(acc, plugin[method](...args), plugin, idx + 1);
89
99
  });
90
100
  return acc;
91
101
  }
@@ -6,7 +6,7 @@
6
6
  interface BasePlugin {
7
7
  /** Unique identifier, used for logs / debugging and for enable/disable. */
8
8
  readonly name: string;
9
- /** Weight; the highest priority wins (default = 0). */
9
+ /** Weight; the highest priority wins (default = 0). Ties keep registration order. */
10
10
  readonly priority?: number;
11
11
  /** Shortcut to disable a plugin without removing it. */
12
12
  enabled?: boolean;
@@ -40,7 +40,9 @@ type PipelineReducer<R, P extends PluginContract> = (
40
40
  interface InvokeOptions<P extends PluginContract, K extends keyof P> {
41
41
  /**
42
42
  * `'first'` → returns the first enabled plugin with the highest priority.
43
- * `'pipeline'` → runs every enabled plugin, combining results via `reducer`.
43
+ * `'pipeline'` → fan-out + reduce: every enabled plugin runs against the
44
+ * same original `args` (results are NOT chained into the next plugin's
45
+ * input), and the results are folded together via `reducer`.
44
46
  * default = `'first'`.
45
47
  */
46
48
  mode?: "first" | "pipeline";
@@ -9,6 +9,18 @@ export interface DeriveAsOfOptions {
9
9
  readonly maxFutureYears?: number;
10
10
  }
11
11
 
12
+ /**
13
+ * Derives the `asOf` instant an evaluation is pinned to.
14
+ *
15
+ * With `asOfSource: "CALLER_PROVIDED"` the instant comes from the reserved
16
+ * `seed.fields.asOf` key (a `Date`); with `"NOW"` it is the evaluation clock.
17
+ *
18
+ * The bound is intentionally asymmetric: a caller-provided `asOf` may be at
19
+ * most `maxFutureYears` in the future (a fat-finger / clock-skew guard against
20
+ * selecting a not-yet-in-force policy), but is unbounded in the past — querying
21
+ * how a policy stood at any historical instant is a first-class use of a
22
+ * temporal engine, so back-dating is never capped.
23
+ */
12
24
  export class PolicyAsOfResolver {
13
25
  private static resolveMaxFutureYears(
14
26
  options: DeriveAsOfOptions,
@@ -195,7 +195,9 @@ export class PolicyCatalogEntry {
195
195
  }
196
196
 
197
197
  equals(other: PolicyCatalogEntry): boolean {
198
- return this.key.equals(other.key);
198
+ // Full variant identity (key + kind + engine/schema versions), not just
199
+ // the key: two variants under the same key are distinct entries.
200
+ return this.toVariantKey() === other.toVariantKey();
199
201
  }
200
202
 
201
203
  toJSON(): PolicyCatalogEntryProps {
@@ -124,7 +124,11 @@ export class PolicyCatalog {
124
124
  return Result.ok(exactMatch);
125
125
  }
126
126
 
127
- if (family.length === 1 && !family[0].hasExplicitVersionSelector()) {
127
+ if (
128
+ family.length === 1 &&
129
+ family[0].kind === params.kind &&
130
+ !family[0].hasExplicitVersionSelector()
131
+ ) {
128
132
  return Result.ok(family[0]);
129
133
  }
130
134
 
@@ -32,6 +32,13 @@ interface ContextResolverCircuitState {
32
32
  readonly openUntilMs: number | null;
33
33
  }
34
34
 
35
+ // The circuit breaker is keyed by context path alone, so its state is shared
36
+ // across every seed a builder serves. This is intentional — the breaker guards
37
+ // the backend behind a path, not a single tenant's request — but it means
38
+ // failures resolving a path for one tenant can trip the breaker for others.
39
+ // Give each isolated blast radius its own PolicyContextBuilder if that scope is
40
+ // wrong for your host.
41
+
35
42
  export interface PolicyContextBuilderOptions {
36
43
  readonly defaultResolverResilience?: ContextResolverResilienceOptions;
37
44
  readonly now?: () => number;
@@ -296,6 +303,12 @@ export class PolicyContextBuilder {
296
303
  return execute();
297
304
  }
298
305
 
306
+ // The timeout is a race, not a cancellation: on timeout the losing
307
+ // resolver keeps running to completion in the background (there is no
308
+ // AbortSignal in the resolver contract). Combined with retry, a slow
309
+ // resolver can therefore be in flight more than once at a time — safe
310
+ // for the idempotent reads resolvers are required to be (see
311
+ // ContextValueResolver), a hazard for any resolver with side effects.
299
312
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
300
313
 
301
314
  try {
@@ -359,6 +372,13 @@ export class PolicyContextBuilder {
359
372
  /**
360
373
  * Resolves all required context paths and returns a flat context object.
361
374
  * If any required path cannot be resolved, returns an error.
375
+ *
376
+ * Resolution is deliberately sequential and fail-fast: paths resolve in
377
+ * order and the first failure returns immediately, so later resolvers never
378
+ * run once one path fails. This trades the latency-hiding of parallel
379
+ * resolution for skipping downstream (potentially expensive) lookups on a
380
+ * doomed evaluation. It is a tested contract, not an oversight — flip it to
381
+ * `Promise.all` only if you also accept that every resolver always runs.
362
382
  */
363
383
  async build(
364
384
  requirements: readonly string[],
@@ -23,6 +23,11 @@ export interface ContextResolverResilienceOptions {
23
23
  /**
24
24
  * A resolver that knows how to produce a value for a specific context path.
25
25
  * Designed as async to allow future DB/API lookups without breaking the interface.
26
+ *
27
+ * Resolvers MUST be idempotent, side-effect-free reads. The builder's timeout
28
+ * is a race with no cancellation, so a timed-out resolver keeps running; with
29
+ * retry enabled it may also be invoked several times for one path. Anything but
30
+ * a pure lookup here risks duplicated effects.
26
31
  */
27
32
  export interface ContextValueResolver {
28
33
  readonly path: string;
@@ -6,6 +6,17 @@ import type { SchoolId, TenantId } from "../policy-ids.js";
6
6
  * Seed data provided by the use case / caller.
7
7
  * Contains raw IDs and inputs that the context system will resolve into paths.
8
8
  * Each resolver is responsible for validating and extracting what it needs from fields.
9
+ *
10
+ * `tenantId`/`schoolId` are required on every seed, including evaluations scoped
11
+ * at TENANT or GLOBAL level. The `schoolId`/`SCHOOL` vocabulary is deliberately
12
+ * school-first: erp-core is a school ERP core, and a full-control copy targeting
13
+ * another domain is expected to rename these ids to its own leaf scope.
14
+ *
15
+ * `fields` is an open bag, but two keys are reserved by the pipeline and carry
16
+ * meaning beyond "just data":
17
+ * - `now` (a `Date`) pins the evaluation clock;
18
+ * - `asOf` (a `Date`) supplies the instant for `asOfSource: "CALLER_PROVIDED"`.
19
+ * See {@link EvaluateInput} and {@link PolicyAsOfResolver}.
9
20
  */
10
21
  export interface ContextSeed {
11
22
  readonly tenantId: TenantId;
@@ -6,14 +6,12 @@ import type { PolicyDefinitionRepository } from "./policy-definition-repository.
6
6
  import { PolicyScopeMatcher } from "./policy-scope.js";
7
7
 
8
8
  export class InMemoryPolicyDefinitionRepository implements PolicyDefinitionRepository {
9
- private readonly definitions: readonly PolicyDefinition[];
10
9
  private readonly definitionsByPolicyKey: ReadonlyMap<
11
10
  string,
12
11
  readonly PolicyDefinition[]
13
12
  >;
14
13
 
15
14
  constructor(definitions: readonly PolicyDefinition[]) {
16
- this.definitions = definitions;
17
15
  this.definitionsByPolicyKey = definitions.reduce<
18
16
  Map<string, PolicyDefinition[]>
19
17
  >((index, definition) => {
@@ -8,6 +8,15 @@ export type GatePayloadParser = (
8
8
  payload: unknown,
9
9
  ) => ResultType<GatePayload, string>;
10
10
 
11
+ // Versioning decision (deliberate asymmetry with compute):
12
+ // Gate parsers are keyed by GATE ENGINE VERSION, not by `payloadSchemaVersion`.
13
+ // A gate definition's `payloadSchemaVersion` therefore selects a catalog
14
+ // variant but has NO effect on parsing — under one gate engine version there is
15
+ // exactly one payload shape. Compute is the opposite: its parsers are keyed by
16
+ // `payloadSchemaVersion` (see parse-compute-payload.ts), because a compute
17
+ // engine version can host several payload schemas. If a second gate payload
18
+ // schema under one engine version ever becomes necessary, migrate gate parsing
19
+ // to be keyed by `payloadSchemaVersion` here to match compute.
11
20
  export class GatePayloadParserRegistry {
12
21
  private readonly parsers = new Map<number, GatePayloadParser>();
13
22
 
@@ -0,0 +1,112 @@
1
+ // Pure helpers for treating condition operands as instants. Extracted from
2
+ // condition-evaluator.ts to keep that file under the kit's size limit and to
3
+ // isolate the (zod-free) date logic the ./abac subpath re-uses. Import the date
4
+ // util directly (not the utils barrel): the barrel re-exports PolicyHashing,
5
+ // whose node:crypto import would needlessly drag a Node-only API into the
6
+ // zod-free ./abac subpath.
7
+ import { PolicyDateUtils } from "../../utils/date.js";
8
+ import { Result } from "../../../result/result.js";
9
+
10
+ import type { ConditionOp } from "./condition-types.js";
11
+
12
+ export type RelationalOperator = Extract<
13
+ ConditionOp,
14
+ "gt" | "gte" | "lt" | "lte"
15
+ >;
16
+
17
+ /**
18
+ * The one instant format condition date operands may use. Strict on purpose
19
+ * (UTC, milliseconds) so comparisons are unambiguous across time zones — and
20
+ * so a malformed operand fails loudly instead of comparing wrong.
21
+ */
22
+ export const CANONICAL_UTC_DATE_FORMAT = "YYYY-MM-DDTHH:mm:ss.SSSZ";
23
+
24
+ const ISO_8601_UTC_DATE_PATTERN =
25
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
26
+ // A value that is clearly meant to be a date-time but isn't canonical UTC
27
+ // (missing milliseconds, a non-UTC offset, a space separator…). Lets the
28
+ // caller report a "malformed date" error instead of a misleading "not a
29
+ // number" one.
30
+ const ISO_8601_DATE_LIKE_PATTERN = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
31
+
32
+ /**
33
+ * Parses a value used as a date operand:
34
+ * - `Result.ok(Date)` for a valid Date or a canonical ISO-8601 UTC string;
35
+ * - `Result.err` when it is date-shaped but malformed (invalid Date, or a
36
+ * date-like string that isn't canonical UTC) — a configuration error worth
37
+ * surfacing rather than silently treating as "not a date";
38
+ * - `null` when the value isn't date-shaped at all, so the caller can fall
39
+ * through to a numeric comparison.
40
+ */
41
+ export function parseComparableDate(
42
+ value: unknown,
43
+ ): Result<Date, string> | null {
44
+ if (value instanceof Date) {
45
+ return PolicyDateUtils.isValid(value)
46
+ ? Result.ok(value)
47
+ : Result.err("Invalid Date instance");
48
+ }
49
+
50
+ if (typeof value !== "string") {
51
+ return null;
52
+ }
53
+
54
+ if (ISO_8601_UTC_DATE_PATTERN.test(value)) {
55
+ const parsed = new Date(value);
56
+ if (
57
+ !PolicyDateUtils.isValid(parsed) ||
58
+ parsed.toISOString() !== value
59
+ ) {
60
+ return Result.err("Invalid ISO 8601 UTC date string");
61
+ }
62
+
63
+ return Result.ok(parsed);
64
+ }
65
+
66
+ if (ISO_8601_DATE_LIKE_PATTERN.test(value)) {
67
+ return Result.err(
68
+ `date operand must be canonical ISO 8601 UTC (${CANONICAL_UTC_DATE_FORMAT})`,
69
+ );
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ export function evaluateRelationalNumbers(
76
+ actual: number,
77
+ op: RelationalOperator,
78
+ expected: number,
79
+ ): boolean {
80
+ switch (op) {
81
+ case "gt":
82
+ return actual > expected;
83
+ case "gte":
84
+ return actual >= expected;
85
+ case "lt":
86
+ return actual < expected;
87
+ case "lte":
88
+ return actual <= expected;
89
+ }
90
+ }
91
+
92
+ export function containsInvalidDate(value: unknown): boolean {
93
+ if (value instanceof Date) {
94
+ return !PolicyDateUtils.isValid(value);
95
+ }
96
+ if (Array.isArray(value)) {
97
+ return value.some((item) => containsInvalidDate(item));
98
+ }
99
+ return false;
100
+ }
101
+
102
+ // Valid Dates become their ISO string so equality/set operators compare
103
+ // instants instead of references; every other value passes through.
104
+ export function normalizeDateOperand(value: unknown): unknown {
105
+ if (value instanceof Date) {
106
+ return value.toISOString();
107
+ }
108
+ if (Array.isArray(value)) {
109
+ return value.map((item) => normalizeDateOperand(item));
110
+ }
111
+ return value;
112
+ }