@classytic/loyalty 0.5.0 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -1,231 +1,321 @@
1
- # Changelog
2
-
3
- Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
- adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
-
6
- ## [0.5.0] — unpublished (2026-07-15)
7
-
8
- ### Changed — mongokit 3.22 adopted (peer floors: mongokit >=3.22.0, repo-core >=0.13.0, primitives >=0.11.0)
9
-
10
- - **Points ledger fence enforced** — `PointTransaction` rows are the
11
- balance audit trail; `appendOnlyPlugin` now refuses every mutation
12
- path (405 `APPEND_ONLY_VIOLATION`). Previously nothing enforced it —
13
- a stray `update()` could rewrite an earn/burn row and silently
14
- desync the audit trail from member balances. The expiry sweeper's
15
- `expiredAt` stamp is the one engine-owned lifecycle write and passes
16
- the audited `bypassAppendOnly` escape.
17
- - No FSM boilerplate existed (no assert+claim sites, no
18
- read-modify-write history) — loyalty was already clean there; this
19
- release is the fence + kit refresh (clean registry install, no
20
- legacy flags).
21
-
22
- ## [0.4.0] — addendum (2026-07-05)
23
-
24
- ### Changed (behavioral)
25
-
26
- - **§P8.1 relay-only session guard** — `UnmanagedSessionError` now throws
27
- only when a host session arrives with NEITHER a `[PENDING_EVENTS]`
28
- queue NOR an outbox. Session + outbox + no queue is legal
29
- durable-relay-only mode: the row commits with the host transaction, the
30
- relay delivers post-commit, the immediate transport publish is skipped.
31
- (Previously the guard was stricter than spec and threw even with an
32
- outbox wired.) Aligns loyalty with invoice / catalog / order /
33
- purchase / transfer / promo / payrun.
34
- - **Stack floors raised** — peers `@classytic/mongokit >=3.18.0`,
35
- `@classytic/repo-core >=0.7.0`; full suite verified against those
36
- versions.
37
-
38
- ## [0.4.0] — 2026-07-03
39
-
40
- ### Fixed
41
-
42
- - **`evaluateAction` is now idempotent per recurrence key.** The order path
43
- dedupes with `${orderId}:${ruleId}`, but the action path (signup /
44
- referral / birthday / review) never set an `idempotencyKey` — a retried
45
- sweep or double-fired event double-awarded points. When `referenceId` is
46
- supplied, each rule award now carries `${referenceId}:${ruleId}`; callers
47
- encode the recurrence period in `referenceId` (e.g.
48
- `birthday:2026:<memberId>`) and the unique partial index on
49
- `idempotencyKey` becomes the authoritative once-only guard.
50
- - **Action rules honor `conditions` now.** `evaluateAction` skipped
51
- `matchesConditions` entirely, silently ignoring admin-authored
52
- `tiers` / `dateRange` / `dayOfWeek` on action-type rules ("only gold
53
- members get birthday points" did nothing). Conditions now apply;
54
- `minOrderAmount` is skipped for actions (no order to measure).
55
- - **`allowNonTransactional` now actually works at runtime.** The flag
56
- silenced the boot capability gate but `MongoUnitOfWork.withTransaction`
57
- still opened a real transaction unconditionally — on standalone Mongo
58
- every `earnPoints`/`redeem` unit failed with "does not support retryable
59
- writes" despite the documented escape hatch. The unit of work now
60
- delegates to mongokit's `withTransaction` with `allowFallback` so the
61
- transactions-unsupported error re-runs the unit on a plain session
62
- (partial-write risk accepted, exactly what the flag opts into).
63
- - Day arithmetic (tier-downgrade grace expiry, referral rate-limit window)
64
- now uses `addDays` from `@classytic/primitives/calendar` — exact 24h × n
65
- durations instead of local `setDate`, which shifted with the deploy
66
- machine's `TZ` env. Primitives peer bumped to `>=0.9.0`.
67
-
68
- ## [0.3.0] — 2026-06-13
69
-
70
- Stack upgrade to mongokit 3.16 / repo-core 0.6 / primitives 0.7.2 plus
71
- verified correctness + PACKAGE_RULES compliance fixes. No new features.
72
-
73
- ### Fixed
74
-
75
- - **Stack actually resolves the new floor now (CRITICAL).** The committed
76
- lockfile silently served `@classytic/mongokit@3.13.0` +
77
- `@classytic/repo-core@0.4.0` despite the `^3.13.3` / `^0.4.2` pins, so the
78
- whole test suite ran against the OLD stack. Clean reinstall + peer/dev bumps
79
- pin the resolved tree to mongokit **3.16.0**, repo-core **0.6.0**, primitives
80
- **0.7.2** (verified with `npm ls`).
81
- - **§P8 outbox is now session-bound on the transactional verbs.** `earnPoints`,
82
- `adjustPoints`, `reserve`, `confirm`, `release`, and `reverseConfirmed`
83
- previously dispatched the event AFTER `withTransaction` committed, so
84
- `outbox.save` ran OUTSIDE the unit of work — the business doc could commit
85
- while the durable event row was lost (or vice-versa). Dispatch now runs
86
- INSIDE the transaction (outbox row commits atomically with the write) and the
87
- transport publish is deferred to a post-commit flush (`runLoyaltyUnit` +
88
- `[PENDING_EVENTS]`), so in-process subscribers never see a state that may roll
89
- back. A new integration test asserts the save is session-bound and that a
90
- failing `outbox.save` rolls the `$inc` balance write back.
91
- - **Redemption slot no longer permanently blocked after release.** The
92
- `{ ownerType, ownerId }` unique index spanned ALL statuses, so a released /
93
- reversed reservation E11000-blocked any retry on the same order (payment
94
- retry, cart re-attempt) forever. It is now a **partial** unique index scoped
95
- to active statuses (`reserved`, `confirmed`) — terminal states free the slot
96
- (PACKAGE_RULES rule 32/38/39). Re-reserve-after-release and second-active-
97
- reserve-blocked tests added.
98
-
99
- ### Added
100
-
101
- - **`UnmanagedSessionError` guard (§P8).** When a host passes `ctx.session`
102
- (it owns the outer transaction) without attaching a `[PENDING_EVENTS]` queue,
103
- dispatch throws BEFORE any side effect instead of publishing / saving outside
104
- the host's unit of work (which would leak ghost events on rollback).
105
- `PENDING_EVENTS` and `flushPendingLoyaltyEvents` are exported for hosts that
106
- manage their own transaction.
107
- - **Capability boot gate.** `createLoyaltyEngine()` asserts the backend declares
108
- the `transactions` capability (repo-core 0.6 / mongokit 3.16+) at engine
109
- creation — fail loud at boot rather than with a cryptic driver error on the
110
- first transactional call. `allowNonTransactional: true` opts out for local-dev
111
- on standalone Mongo. New `LoyaltyCapabilityError`.
112
- - **Cooperative cancellation in cron loops.** `processExpirations`, `awardBatch`,
113
- `cleanupExpired` (redemption + referral), `evaluateAll`, and
114
- `sweepExpiredOverrides` check `ctx.signal` via `throwIfAborted` between items —
115
- abortable batches; each per-item write is its own committed unit of work, so
116
- aborting never rolls back already-processed rows. `LoyaltyContext` gained
117
- `signal?: AbortSignal`.
118
-
119
- ### Changed
120
-
121
- - **Domain errors implement `HttpError`** from `@classytic/repo-core/errors`
122
- (was a bespoke `httpStatus` shape). Clean break: `httpStatus` → `status`,
123
- and codes are now hierarchical + lowercase (`loyalty.<entity>.<problem>`,
124
- e.g. `loyalty.member.not_found`, `loyalty.points.insufficient`) so arc
125
- serializes them to `ErrorContract` correctly. No deprecation alias.
126
- - **Peer-dep ranges raised** — `@classytic/mongokit >=3.16.0`,
127
- `@classytic/repo-core >=0.6.0` (`>=` style). devDeps bumped to match and
128
- primitives devDep pinned to `^0.7.2`.
129
- - **`exactOptionalPropertyTypes: true`** enabled in tsconfig (mandatory per
130
- PACKAGE_RULES Build Hygiene / P10); fixed the three pre-existing optional-vs-
131
- undefined violations it surfaced.
132
- - **tsconfig no longer emits sourcemaps or declaration maps**
133
- (`sourceMap: false`, `declarationMap: false`) — both leaked source. tsdown
134
- was already correct; tsconfig now matches.
135
-
136
- ## [0.2.3] — 2026-05-08
137
-
138
- ### Fixed
139
-
140
- - **§P8 dispatch — `outbox.save` failures now propagate.** [src/events/dispatch.ts](src/events/dispatch.ts)
141
- previously caught `outbox.save` errors and swallowed them silently. Per
142
- PACKAGE_RULES §P8 the outbox row is the durability anchor for the
143
- transactional-outbox pattern: if the save fails, the caller's transaction
144
- MUST roll back so the business doc and the event row land atomically.
145
- Swallowing the failure meant a parent doc could commit while the event
146
- vanished, with no relay row to recover from. Fix re-throws after logging.
147
- `events.publish` failures continue to be swallowed (correctly, per §P8 —
148
- the host's outbox relay re-publishes from the durable row), but now log
149
- before swallowing so failures are observable.
150
- - **`DispatchDeps.logger` field added.** Optional. Both arms of the
151
- dispatch helper log through it when set, surfacing failures in the host's
152
- observability stack without coupling the package to a specific logger.
153
-
154
- ### Changed
155
-
156
- - **Peer-dep ranges tightened** — `@classytic/mongokit` `>=3.13.3`
157
- (was `>=3.13.0`) and `@classytic/repo-core` `>=0.4.2` (was `>=0.4.0`).
158
- 3.13.3 preserves `errorLabels` on `MongoServerError` so concurrent
159
- transactional writes (the `claim()`-based redemption + tier-eval paths)
160
- retry correctly under contention; 0.4.2 adds the typed `FindAllOptions`
161
- shape with `limit?` for callers that need a bounded non-paginated read.
162
-
163
- ### Internal
164
-
165
- - Replaced 4 `arr.find()!` non-null assertions in [src/index.ts](src/index.ts)
166
- + scenario tests with explicit narrowing — TS now proves what the runtime
167
- shape already guaranteed.
168
-
169
- ## [0.2.0]
170
-
171
- Structural rewrite on top of the 0.1.0 shape. The public API is now a
172
- smaller, sharper surface organized around seven subpath exports
173
- (`/domain/enums`, `/models`, `/repositories`, `/events`, `/types`,
174
- `/notifications`, plus the root engine factory). Legacy barrel + service
175
- layers were removed; hosts wire business flows directly against the
176
- repositories and the engine factory.
177
-
178
- ### Changed
179
-
180
- - **Domain restructure.** Dropped the `src/domain/ports/` + `src/services/`
181
- layer in favor of thin repositories that expose exactly the operations
182
- hosts need — `earn`, `redeem`, `adjust`, `expire`, referral credit,
183
- tier recomputation. No more `earning.service` / `redemption.service` /
184
- `tier.service` / `referral.service` / `ledger.service` / `member.service`
185
- / `member-guard` indirections; the equivalent logic lives on the
186
- repositories where it's naturally atomic.
187
- - **Events subsystem simplified.** Removed `events/event-bus.ts` +
188
- `events/event-types.ts` (both are internal indirection that the engine
189
- didn't need). Event payloads are now colocated with the repository
190
- that emits them.
191
- - **Repositories updated to mongokit 3.11 + repo-core 0.2 conventions.**
192
- Every repo extends `Repository<T>` directly, uses the tenant plugin via
193
- `injectTenant`, and atomic state changes go through CAS guards.
194
- - **Peer deps** unchanged in spirit, tightened in range:
195
- - `@classytic/mongokit >= 3.11`
196
- - `@classytic/primitives >= 0.1`
197
- - `@classytic/repo-core >= 0.2`
198
- - `mongoose >= 9.4`, `zod >= 4`
199
- - **DevDeps:** `@classytic/primitives` devDep moved from `file:` to `>=0.1.0`
200
- (now that primitives ships on npm); all `@classytic/*` dev ranges
201
- standardized on `>=`.
202
-
203
- ### Removed
204
-
205
- - `src/services/*` — logic folded into repositories.
206
- - `src/domain/ports/*` barrels and per-port files — structural types
207
- now live with the repositories they describe.
208
- - Event-bus indirection — repositories emit via the engine's configured
209
- transport directly.
210
-
211
- ### Added
212
-
213
- - Subpath export `/notifications` — notification port + built-in
214
- lifecycle payloads (tier up/down, points expiring, referral credit).
215
- - Test restructure — 257 tests across unit + integration projects; the
216
- deleted legacy suites were collapsed into focused scenario files that
217
- exercise the new public API.
218
-
219
- ### Peer compatibility
220
-
221
- Anyone on 0.1.0 wiring against the old `*.service.ts` modules will need
222
- to migrate to the repository methods. Signatures are similar — earn /
223
- redeem / adjust remain the nouns — but the call sites move from
224
- `services.earning.earnPoints(...)` to `repositories.pointTransaction.earn(...)`
225
- and similar. No behavior changes in the ledger semantics.
226
-
227
- ## [0.1.0]
228
-
229
- Initial release. Loyalty points, tiers, earning rules, referrals, and
230
- redemption engine for MongoDB. Tenant-scoped, event-emitting, atomic
231
- point-ledger with CAS guards.
1
+ # Changelog
2
+
3
+ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.6.0] — 2026-08-17
7
+
8
+ ### Added
9
+ - `SessionNotInTransactionError` — thrown when a host passes `ctx.session` that has no active transaction; a bare session provides no atomicity and would leave the points mutation standing if the outbox save fails
10
+ - `ResolvedTenant` type alias (deprecated shim for `ResolvedTenantConfig` from `@classytic/repo-core/tenant`)
11
+
12
+ ### Changed
13
+ - `TenantConfig` widened to `RepoTenantConfig | boolean` (was `false | PrimitivesTenantConfig`); hosts rename `field` → `tenantField` and `type` → `fieldType`
14
+ - `in-process-bus` consolidated onto primitives `InProcessEventBus`; event catalog, dispatch, and repository types aligned
15
+ - Schemas and repositories updated: earning-rule, redemption, tier-definition, point-transaction, referral
16
+ - `src/index.ts` exports reorganised for tree-shaking
17
+
18
+ ## 1.0.0 - 2026-07-29
19
+
20
+ ### Changed
21
+ - **License:** relicensed from MIT to the **Classytic Source-Available License** (Community & Commercial). Evaluation/development use remains free; production use now requires a commercial license from Classytic LLC. See `LICENSE`.
22
+ - Major bump marks the license change; versions published before 1.0.0 remain under their original MIT terms.
23
+
24
+ ## 0.7.1 - 2026-07-22 (unpublished)
25
+
26
+ ### Changed
27
+ - Internal: `InProcess*Bus` is now a thin subclass of `@classytic/primitives/event-infra`'s shared `InProcessEventBus` (dedup / error-isolation / close semantics unchanged); `MemoryOutboxStore` re-exported from the shared runtime where applicable. No public API, event-name, or wire-envelope change. Requires `@classytic/primitives >=0.13.0`.
28
+
29
+ ## [0.7.0] — unpublished (2026-07-22)
30
+
31
+ ### Added — engine lifecycle: `destroy()`
32
+
33
+ - **`LoyaltyEngine.destroy()`** — closes the self-created
34
+ `InProcessLoyaltyBus` (skipped when the host injected `eventTransport`).
35
+ - `destroy()` follows the fleet ownsBus convention (manufacturing/purchase
36
+ pattern): only what the engine itself created is closed; a host-injected
37
+ transport is the host's to close. Idempotent — a second call is a no-op.
38
+ Mongoose models/connection are untouched (host-owned).
39
+ - Lifecycle coverage in `tests/integration/engine-lifecycle.test.ts`.
40
+
41
+ ## [0.6.0] — 2026-07-20
42
+
43
+ ### Changed — money contract: integer MINOR units + explicit rates, no major-unit ambiguity (BREAKING)
44
+
45
+ Every monetary number in loyalty was an ambiguous major-currency float
46
+ (unenforced), and both earning rates carried an implicit "per 1 MAJOR unit"
47
+ basis. Aligned with `@classytic/flow@0.6.0`'s TOTAL-vs-RATE standardization
48
+ (see flow `wiki/conventions.md` §15). Loyalty stays ambient-single-currency —
49
+ NO currency field on any doc or API; the host owns currency.
50
+
51
+ - **All money AMOUNTS are now integers in the host's minor unit** (paisa for
52
+ BDT), enforced by `mongooseMinorUnits()` from the new
53
+ **`@classytic/validation` dependency (`>=0.2.0`)**:
54
+ - `EarningConditions.minOrderAmount`
55
+ - `LoyaltyConfig.redemption.minOrderAmount` (`DEFAULT_MIN_ORDER_AMOUNT`
56
+ stays `0` — zero is unit-invariant)
57
+ - `Redemption.discountAmount`, `Redemption.initialBalance`,
58
+ `Redemption.remainingBalance`
59
+ - `TierQualification.minLifetimeSpend` (declared-only field — tier checks
60
+ still use lifetime POINTS; re-documented + validated, no math change)
61
+ - API inputs: `evaluateOrder({ orderTotal, items[].amount })` and
62
+ `validate`/`reserve({ orderTotal })`. `validateOrderTotal` now requires a
63
+ non-negative **safe integer** ("orderTotal must be a non-negative safe
64
+ integer (minor units)") — floats and majors fail loud.
65
+ - **`EarningReward.amountPerPoint`** is now MINOR UNITS PER POINT (integer
66
+ ≥ 1, bespoke rate validator). "1 point per ৳100" = `amountPerPoint: 10000`.
67
+ - **`EarningReward.pointsPerAmount` requires a new sibling `rateBasis`**
68
+ (integer minor units ≥ 1): `points = pointsPerAmount × (orderTotal /
69
+ rateBasis)`, rounded ONCE per the rule's `roundingMode`. The old implicit
70
+ "points per 1 MAJOR unit" basis was the bug being killed. Pairing enforced
71
+ at the schema level (pre-validate) AND by a runtime `ValidationError` guard
72
+ in the calculators (legacy/hand-written docs fail loud).
73
+ - **`conversionRate` (points per 1 MAJOR unit) is REPLACED by
74
+ `pointValueMinor`** (integer minor units ONE point redeems for, ≥ 1) on
75
+ `LoyaltyConfig.program`, `ResolvedConfig`, and the `Redemption` doc snapshot.
76
+ `conversionRate: 10` (10 pts per ৳1) ≡ `pointValueMinor: 10` (1 pt = 10
77
+ paisa). `DEFAULT_CONVERSION_RATE` → `DEFAULT_POINT_VALUE_MINOR` (still `10`
78
+ — same economic default). Boot validation: "pointValueMinor must be a
79
+ positive integer (minor units per point)".
80
+ - **Redemption math is now EXACT integer arithmetic** (no more floor-division
81
+ remainder): `requestedDiscountMinor = pointsToRedeem × pointValueMinor`;
82
+ percent cap `maxDiscountMinor = floor(orderTotal × maxRedeemPercent / 100)`
83
+ unchanged in shape; when capped, `actualPoints = floor(cappedDiscount /
84
+ pointValueMinor)` and the discount is **recomputed as `actualPoints ×
85
+ pointValueMinor`** — the granted discount ALWAYS equals exactly the points
86
+ charged (no orphaned remainder paisa; documented invariant).
87
+ - **Event contract**: `RedemptionPayload.discountAmount` is integer minor
88
+ units; the Zod catalog schema now asserts `.int().nonnegative()` (Zod event
89
+ schemas are not tsc-checked against the TS interfaces — flow's lesson).
90
+
91
+ **Migration**: clean cutover, no dual-read shim. Re-express persisted majors
92
+ as minors (× minor-unit factor), rename `conversionRate` → `pointValueMinor`
93
+ (config + redemption docs), and add `rateBasis` to every rule that sets
94
+ `pointsPerAmount` (old implicit basis "per ৳1" = `rateBasis: 100`).
95
+
96
+ ## [0.5.0] — unpublished (2026-07-15)
97
+
98
+ ### Changed — mongokit 3.22 adopted (peer floors: mongokit >=3.22.0, repo-core >=0.13.0, primitives >=0.11.0)
99
+
100
+ - **Points ledger fence enforced** — `PointTransaction` rows are the
101
+ balance audit trail; `appendOnlyPlugin` now refuses every mutation
102
+ path (405 `APPEND_ONLY_VIOLATION`). Previously nothing enforced it —
103
+ a stray `update()` could rewrite an earn/burn row and silently
104
+ desync the audit trail from member balances. The expiry sweeper's
105
+ `expiredAt` stamp is the one engine-owned lifecycle write and passes
106
+ the audited `bypassAppendOnly` escape.
107
+ - No FSM boilerplate existed (no assert+claim sites, no
108
+ read-modify-write history) — loyalty was already clean there; this
109
+ release is the fence + kit refresh (clean registry install, no
110
+ legacy flags).
111
+
112
+ ## [0.4.0] — addendum (2026-07-05)
113
+
114
+ ### Changed (behavioral)
115
+
116
+ - **§P8.1 relay-only session guard** — `UnmanagedSessionError` now throws
117
+ only when a host session arrives with NEITHER a `[PENDING_EVENTS]`
118
+ queue NOR an outbox. Session + outbox + no queue is legal
119
+ durable-relay-only mode: the row commits with the host transaction, the
120
+ relay delivers post-commit, the immediate transport publish is skipped.
121
+ (Previously the guard was stricter than spec and threw even with an
122
+ outbox wired.) Aligns loyalty with invoice / catalog / order /
123
+ purchase / transfer / promo / payrun.
124
+ - **Stack floors raised** — peers `@classytic/mongokit >=3.18.0`,
125
+ `@classytic/repo-core >=0.7.0`; full suite verified against those
126
+ versions.
127
+
128
+ ## [0.4.0] — 2026-07-03
129
+
130
+ ### Fixed
131
+
132
+ - **`evaluateAction` is now idempotent per recurrence key.** The order path
133
+ dedupes with `${orderId}:${ruleId}`, but the action path (signup /
134
+ referral / birthday / review) never set an `idempotencyKey` — a retried
135
+ sweep or double-fired event double-awarded points. When `referenceId` is
136
+ supplied, each rule award now carries `${referenceId}:${ruleId}`; callers
137
+ encode the recurrence period in `referenceId` (e.g.
138
+ `birthday:2026:<memberId>`) and the unique partial index on
139
+ `idempotencyKey` becomes the authoritative once-only guard.
140
+ - **Action rules honor `conditions` now.** `evaluateAction` skipped
141
+ `matchesConditions` entirely, silently ignoring admin-authored
142
+ `tiers` / `dateRange` / `dayOfWeek` on action-type rules ("only gold
143
+ members get birthday points" did nothing). Conditions now apply;
144
+ `minOrderAmount` is skipped for actions (no order to measure).
145
+ - **`allowNonTransactional` now actually works at runtime.** The flag
146
+ silenced the boot capability gate but `MongoUnitOfWork.withTransaction`
147
+ still opened a real transaction unconditionally — on standalone Mongo
148
+ every `earnPoints`/`redeem` unit failed with "does not support retryable
149
+ writes" despite the documented escape hatch. The unit of work now
150
+ delegates to mongokit's `withTransaction` with `allowFallback` so the
151
+ transactions-unsupported error re-runs the unit on a plain session
152
+ (partial-write risk accepted, exactly what the flag opts into).
153
+ - Day arithmetic (tier-downgrade grace expiry, referral rate-limit window)
154
+ now uses `addDays` from `@classytic/primitives/calendar` — exact 24h × n
155
+ durations instead of local `setDate`, which shifted with the deploy
156
+ machine's `TZ` env. Primitives peer bumped to `>=0.9.0`.
157
+
158
+ ## [0.3.0] — 2026-06-13
159
+
160
+ Stack upgrade to mongokit 3.16 / repo-core 0.6 / primitives 0.7.2 plus
161
+ verified correctness + PACKAGE_RULES compliance fixes. No new features.
162
+
163
+ ### Fixed
164
+
165
+ - **Stack actually resolves the new floor now (CRITICAL).** The committed
166
+ lockfile silently served `@classytic/mongokit@3.13.0` +
167
+ `@classytic/repo-core@0.4.0` despite the `^3.13.3` / `^0.4.2` pins, so the
168
+ whole test suite ran against the OLD stack. Clean reinstall + peer/dev bumps
169
+ pin the resolved tree to mongokit **3.16.0**, repo-core **0.6.0**, primitives
170
+ **0.7.2** (verified with `npm ls`).
171
+ - **§P8 outbox is now session-bound on the transactional verbs.** `earnPoints`,
172
+ `adjustPoints`, `reserve`, `confirm`, `release`, and `reverseConfirmed`
173
+ previously dispatched the event AFTER `withTransaction` committed, so
174
+ `outbox.save` ran OUTSIDE the unit of work — the business doc could commit
175
+ while the durable event row was lost (or vice-versa). Dispatch now runs
176
+ INSIDE the transaction (outbox row commits atomically with the write) and the
177
+ transport publish is deferred to a post-commit flush (`runLoyaltyUnit` +
178
+ `[PENDING_EVENTS]`), so in-process subscribers never see a state that may roll
179
+ back. A new integration test asserts the save is session-bound and that a
180
+ failing `outbox.save` rolls the `$inc` balance write back.
181
+ - **Redemption slot no longer permanently blocked after release.** The
182
+ `{ ownerType, ownerId }` unique index spanned ALL statuses, so a released /
183
+ reversed reservation E11000-blocked any retry on the same order (payment
184
+ retry, cart re-attempt) forever. It is now a **partial** unique index scoped
185
+ to active statuses (`reserved`, `confirmed`) — terminal states free the slot
186
+ (PACKAGE_RULES rule 32/38/39). Re-reserve-after-release and second-active-
187
+ reserve-blocked tests added.
188
+
189
+ ### Added
190
+
191
+ - **`UnmanagedSessionError` guard (§P8).** When a host passes `ctx.session`
192
+ (it owns the outer transaction) without attaching a `[PENDING_EVENTS]` queue,
193
+ dispatch throws BEFORE any side effect instead of publishing / saving outside
194
+ the host's unit of work (which would leak ghost events on rollback).
195
+ `PENDING_EVENTS` and `flushPendingLoyaltyEvents` are exported for hosts that
196
+ manage their own transaction.
197
+ - **Capability boot gate.** `createLoyaltyEngine()` asserts the backend declares
198
+ the `transactions` capability (repo-core 0.6 / mongokit 3.16+) at engine
199
+ creation — fail loud at boot rather than with a cryptic driver error on the
200
+ first transactional call. `allowNonTransactional: true` opts out for local-dev
201
+ on standalone Mongo. New `LoyaltyCapabilityError`.
202
+ - **Cooperative cancellation in cron loops.** `processExpirations`, `awardBatch`,
203
+ `cleanupExpired` (redemption + referral), `evaluateAll`, and
204
+ `sweepExpiredOverrides` check `ctx.signal` via `throwIfAborted` between items —
205
+ abortable batches; each per-item write is its own committed unit of work, so
206
+ aborting never rolls back already-processed rows. `LoyaltyContext` gained
207
+ `signal?: AbortSignal`.
208
+
209
+ ### Changed
210
+
211
+ - **Domain errors implement `HttpError`** from `@classytic/repo-core/errors`
212
+ (was a bespoke `httpStatus` shape). Clean break: `httpStatus` → `status`,
213
+ and codes are now hierarchical + lowercase (`loyalty.<entity>.<problem>`,
214
+ e.g. `loyalty.member.not_found`, `loyalty.points.insufficient`) so arc
215
+ serializes them to `ErrorContract` correctly. No deprecation alias.
216
+ - **Peer-dep ranges raised** — `@classytic/mongokit >=3.16.0`,
217
+ `@classytic/repo-core >=0.6.0` (`>=` style). devDeps bumped to match and
218
+ primitives devDep pinned to `^0.7.2`.
219
+ - **`exactOptionalPropertyTypes: true`** enabled in tsconfig (mandatory per
220
+ PACKAGE_RULES Build Hygiene / P10); fixed the three pre-existing optional-vs-
221
+ undefined violations it surfaced.
222
+ - **tsconfig no longer emits sourcemaps or declaration maps**
223
+ (`sourceMap: false`, `declarationMap: false`) — both leaked source. tsdown
224
+ was already correct; tsconfig now matches.
225
+
226
+ ## [0.2.3] — 2026-05-08
227
+
228
+ ### Fixed
229
+
230
+ - **§P8 dispatch — `outbox.save` failures now propagate.** [src/events/dispatch.ts](src/events/dispatch.ts)
231
+ previously caught `outbox.save` errors and swallowed them silently. Per
232
+ PACKAGE_RULES §P8 the outbox row is the durability anchor for the
233
+ transactional-outbox pattern: if the save fails, the caller's transaction
234
+ MUST roll back so the business doc and the event row land atomically.
235
+ Swallowing the failure meant a parent doc could commit while the event
236
+ vanished, with no relay row to recover from. Fix re-throws after logging.
237
+ `events.publish` failures continue to be swallowed (correctly, per §P8 —
238
+ the host's outbox relay re-publishes from the durable row), but now log
239
+ before swallowing so failures are observable.
240
+ - **`DispatchDeps.logger` field added.** Optional. Both arms of the
241
+ dispatch helper log through it when set, surfacing failures in the host's
242
+ observability stack without coupling the package to a specific logger.
243
+
244
+ ### Changed
245
+
246
+ - **Peer-dep ranges tightened** — `@classytic/mongokit` `>=3.13.3`
247
+ (was `>=3.13.0`) and `@classytic/repo-core` `>=0.4.2` (was `>=0.4.0`).
248
+ 3.13.3 preserves `errorLabels` on `MongoServerError` so concurrent
249
+ transactional writes (the `claim()`-based redemption + tier-eval paths)
250
+ retry correctly under contention; 0.4.2 adds the typed `FindAllOptions`
251
+ shape with `limit?` for callers that need a bounded non-paginated read.
252
+
253
+ ### Internal
254
+
255
+ - Replaced 4 `arr.find()!` non-null assertions in [src/index.ts](src/index.ts)
256
+ + scenario tests with explicit narrowing — TS now proves what the runtime
257
+ shape already guaranteed.
258
+
259
+ ## [0.2.0]
260
+
261
+ Structural rewrite on top of the 0.1.0 shape. The public API is now a
262
+ smaller, sharper surface organized around seven subpath exports
263
+ (`/domain/enums`, `/models`, `/repositories`, `/events`, `/types`,
264
+ `/notifications`, plus the root engine factory). Legacy barrel + service
265
+ layers were removed; hosts wire business flows directly against the
266
+ repositories and the engine factory.
267
+
268
+ ### Changed
269
+
270
+ - **Domain restructure.** Dropped the `src/domain/ports/` + `src/services/`
271
+ layer in favor of thin repositories that expose exactly the operations
272
+ hosts need — `earn`, `redeem`, `adjust`, `expire`, referral credit,
273
+ tier recomputation. No more `earning.service` / `redemption.service` /
274
+ `tier.service` / `referral.service` / `ledger.service` / `member.service`
275
+ / `member-guard` indirections; the equivalent logic lives on the
276
+ repositories where it's naturally atomic.
277
+ - **Events subsystem simplified.** Removed `events/event-bus.ts` +
278
+ `events/event-types.ts` (both are internal indirection that the engine
279
+ didn't need). Event payloads are now colocated with the repository
280
+ that emits them.
281
+ - **Repositories updated to mongokit 3.11 + repo-core 0.2 conventions.**
282
+ Every repo extends `Repository<T>` directly, uses the tenant plugin via
283
+ `injectTenant`, and atomic state changes go through CAS guards.
284
+ - **Peer deps** unchanged in spirit, tightened in range:
285
+ - `@classytic/mongokit >= 3.11`
286
+ - `@classytic/primitives >= 0.1`
287
+ - `@classytic/repo-core >= 0.2`
288
+ - `mongoose >= 9.4`, `zod >= 4`
289
+ - **DevDeps:** `@classytic/primitives` devDep moved from `file:` to `>=0.1.0`
290
+ (now that primitives ships on npm); all `@classytic/*` dev ranges
291
+ standardized on `>=`.
292
+
293
+ ### Removed
294
+
295
+ - `src/services/*` — logic folded into repositories.
296
+ - `src/domain/ports/*` barrels and per-port files — structural types
297
+ now live with the repositories they describe.
298
+ - Event-bus indirection — repositories emit via the engine's configured
299
+ transport directly.
300
+
301
+ ### Added
302
+
303
+ - Subpath export `/notifications` — notification port + built-in
304
+ lifecycle payloads (tier up/down, points expiring, referral credit).
305
+ - Test restructure — 257 tests across unit + integration projects; the
306
+ deleted legacy suites were collapsed into focused scenario files that
307
+ exercise the new public API.
308
+
309
+ ### Peer compatibility
310
+
311
+ Anyone on 0.1.0 wiring against the old `*.service.ts` modules will need
312
+ to migrate to the repository methods. Signatures are similar — earn /
313
+ redeem / adjust remain the nouns — but the call sites move from
314
+ `services.earning.earnPoints(...)` to `repositories.pointTransaction.earn(...)`
315
+ and similar. No behavior changes in the ledger semantics.
316
+
317
+ ## [0.1.0]
318
+
319
+ Initial release. Loyalty points, tiers, earning rules, referrals, and
320
+ redemption engine for MongoDB. Tenant-scoped, event-emitting, atomic
321
+ point-ledger with CAS guards.