@classytic/primitives 0.20.0 → 0.22.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 (51) hide show
  1. package/CHANGELOG.md +934 -906
  2. package/dist/approval.d.mts +3 -3
  3. package/dist/bank-transaction.d.mts +1 -2
  4. package/dist/{brand-LLXqoh5J.d.mts → brand-Lj_75Y_r.d.mts} +6 -6
  5. package/dist/brand.d.mts +1 -1
  6. package/dist/cadence.d.mts +1 -1
  7. package/dist/cadence.mjs +2 -2
  8. package/dist/calendar.d.mts +1 -1
  9. package/dist/calendar.mjs +2 -2
  10. package/dist/condition.mjs +2 -2
  11. package/dist/context.d.mts +0 -1
  12. package/dist/currency.d.mts +1 -1
  13. package/dist/environment.d.mts +90 -0
  14. package/dist/environment.mjs +53 -0
  15. package/dist/event-infra.d.mts +18 -1
  16. package/dist/idempotency.d.mts +11 -1
  17. package/dist/identity-verification.d.mts +0 -1
  18. package/dist/memory-outbox.d.mts +1 -2
  19. package/dist/memory-outbox.mjs +0 -8
  20. package/dist/mixin.d.mts +1 -1
  21. package/dist/monetization.d.mts +1 -2
  22. package/dist/{money-DPo9fLBi.d.mts → money-CfAvfNiM.d.mts} +1 -2
  23. package/dist/money.d.mts +1 -1
  24. package/dist/otp.mjs +1 -1
  25. package/dist/outbox.d.mts +0 -1
  26. package/dist/payment-allocation-status.d.mts +10 -4
  27. package/dist/payment-allocation-status.mjs +14 -0
  28. package/dist/payment-events.d.mts +2 -3
  29. package/dist/payment-events.mjs +12 -0
  30. package/dist/payment-gateway.d.mts +3 -3
  31. package/dist/payment-method-kind.d.mts +23 -11
  32. package/dist/payment-method-kind.mjs +17 -0
  33. package/dist/period.d.mts +39 -4
  34. package/dist/period.mjs +50 -1
  35. package/dist/person-parse.d.mts +0 -1
  36. package/dist/phone.d.mts +0 -1
  37. package/dist/phone.mjs +3 -3
  38. package/dist/proration.d.mts +1 -2
  39. package/dist/retention.mjs +3 -3
  40. package/dist/shelf-life.mjs +1 -1
  41. package/dist/sla-policy.d.mts +1 -2
  42. package/dist/state-diagram.d.mts +0 -1
  43. package/dist/state-diagram.mjs +1 -1
  44. package/dist/suspension.d.mts +6 -4
  45. package/dist/suspension.mjs +3 -2
  46. package/dist/{timezone-tlG7ygwx.d.mts → timezone-CxUjxmRU.d.mts} +3 -4
  47. package/dist/timezone.d.mts +1 -1
  48. package/dist/unit-cost-rate.d.mts +1 -2
  49. package/package.json +7 -2
  50. /package/dist/{cadence-BFQVC1Qp.d.mts → cadence-5X6TiD49.d.mts} +0 -0
  51. /package/dist/{calendar-B6fm-FbR.d.mts → calendar-DJWSbcgE.d.mts} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,906 +1,934 @@
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.20.0] - 2026-08-05
7
-
8
- ### Added — `./period` OWNS timezone-aware period resolution (was two copies above the kernels)
9
-
10
- `Period` was `{ year, month?, quarter? }` with no timezone and no resolver, so it
11
- could not express "the business month of August in Asia/Dhaka". The resolver
12
- existed TWICE, both ABOVE the kernels — `@spinekit/kit/period`
13
- (`createBusinessCalendar`) and be-prod's `#lib/utils/business-date` — which meant
14
- a kernel could reach neither and re-derived the boundary inline with `Date.UTC`.
15
- Five silent UTC-month defects came out of that (`aggregateMonthlyVat`, the
16
- sales-fact reconciler, and three in bd-tax Mushak).
17
-
18
- This is a MOVE of the proven `createBusinessCalendar` logic, not a re-derivation:
19
- same civil-date round-trip through `/timezone`, same half-open ranges, same
20
- DST-exactness.
21
-
22
- - **`Period.timezone?`** — the IANA zone a period is DEFINED in, so a persisted or
23
- transported period keeps its own calendar.
24
- - **`resolvePeriod(period, timezone?)`** → half-open `DateRange`. Throws when no
25
- zone is available anywhere (never silently answers in UTC) and throws when the
26
- period's zone and the caller's zone DISAGREE.
27
- - **`resolveDay` / `resolveMonth` / `resolveQuarter` / `resolveYear` /
28
- `resolveDateSpan` / `dayStart`** — the granularity-specific forms. All return
29
- HALF-OPEN `[start, end)`, which tiles exactly.
30
- - **`periodOf(instant, granularity, timezone)`** — the inverse: which filing
31
- period an instant belongs to on the LOCAL calendar.
32
- - **`parsePeriod` / `formatPeriod` / `granularityOf` / `periodTimeZone`** —
33
- `'YYYY'` · `'YYYY-MM'` · `'YYYY-Qn'`. `parsePeriod` REFUSES a `'YYYY-MM-DD'`
34
- rather than reading it as its month (a ~30× widening).
35
- - **`inclusiveEnd(range)`** — `end - 1ms` for legacy `$lte` call sites, derived
36
- from the resolved end so it stays DST-exact.
37
- - **`PeriodError`** + **`PeriodErrorCode`** (`AMBIGUOUS_PERIOD` ·
38
- `INCOMPLETE_RANGE` · `INVALID_FIELD` · `MISSING_TIMEZONE` · `TIMEZONE_CONFLICT`
39
- · `INVALID_LABEL`).
40
-
41
- A `Period` carrying two resolution shapes (`month` + `quarter`, or either with
42
- `start`/`end`) now throws instead of preferring one.
43
-
44
- ### Added — `./idempotency`: the claim + lease CONTRACT
45
-
46
- Three kernels hand-rolled incompatible versions of one state machine — `cart`
47
- (`in_flight`/`succeeded`/`failed`, the only one with crash-window leasing),
48
- `catalog` offers (`pending`/`completed`, no lease), `contract` amendments
49
- (implicit). Types + PURE helpers only; no persistence, no collection, no driver.
50
-
51
- - **`ClaimOutcome = 'claimed' | 'replayed' | 'in_flight'`** — three-valued, and
52
- `in_flight` is NOT a failure. Mapping a live concurrent lease onto an error
53
- licenses a retry, which is a double-apply.
54
- - **`IdempotencyClaim`** (`identity` · `requestFingerprint` · `state` ·
55
- `leaseToken` · `leaseExpiresAt` · `attempts` · `result`), **`ClaimResult`**,
56
- **`ClaimState`**, **`IdempotencyIdentity`**.
57
- - **`decideClaim(existing, request)`** — the whole state machine as one pure
58
- function. Throws `FINGERPRINT_MISMATCH` (same key, different body) and
59
- `MISSING_REPLAY` (terminal record with no stored result) rather than answering.
60
- - **`completeClaim` / `renewLease`** — both refuse when the caller lost its lease,
61
- so a superseded attempt cannot overwrite the winner's result.
62
- - **`identityKey`** (canonical-JSON composite, so a delimiter inside a scope value
63
- cannot collide two identities), **`fingerprintRequest`**, **`newLeaseToken`**,
64
- **`isLeaseExpired`**, **`holdsLease`**, **`isInFlight`**, **`DEFAULT_LEASE_MS`**.
65
-
66
- ### Changed — BREAKING: five API decisions taken before spine wiring starts
67
-
68
- - **`./cadence` — `Cadence.timezone` is now AUTHORITATIVE, not display-only.**
69
- It was documented as decorative while `stepMonthly`/`stepYearly` used
70
- `getUTCMonth()`/`getUTCDate()`, so `timezone: 'Asia/Dhaka'` + `dayOfMonth: 1`
71
- with a midnight anchor placed every occurrence on the local **2nd**. All four
72
- kinds now run on the zone's wall clock when one is set (daily advances LOCAL
73
- days — 23h/25h across a DST transition), and stay UTC when it is absent.
74
- `validateCadence` rejects an unresolvable zone with the new
75
- `INVALID_TIMEZONE`. **Behavioural change for any persisted cadence that
76
- already carries a `timezone`** — the occurrences move to where the field
77
- always claimed they were.
78
- - **`./approval` — `assertApproved` no longer passes on a `null` chain.** It now
79
- throws the new `CHAIN_MISSING`. Absence is not approval, and a mongoose schema
80
- that never declared `approvalChain` strips it on write, so the gate could not
81
- tell "no approval configured" from "approval silently lost". The explicit
82
- opt-out is the new **`assertApprovedIfPresent`**. The second parameter is now
83
- an options object (`{ message }`), not a bare string.
84
- - **`./payment-events` — every payload carries an `eventType` discriminant, and
85
- there is a `payment.unknown` event.** `PaymentInitiatedPayload` and
86
- `PaymentSucceededPayload` were structurally IDENTICAL with no discriminant on
87
- the union, so a handler typed for "funds received" accepted "money in flight".
88
- Adds **`PaymentUnknownPayload`** (the port contract's third outcome had no
89
- event, so an unobserved result had to be forced into SUCCEEDED or FAILED),
90
- **`PaymentEventPayloadMap`**, **`isPaymentEvent`**, **`isFundsReceived`**, and
91
- three compile-time drift guards over the map.
92
- - **`./currency` — `convertWithSnapshot` / `reverseWithSnapshot` take and return
93
- `Money`.** A bare `number` meant nothing checked the amount was denominated in
94
- `fx.sourceCurrency`; a USD→BDT snapshot applied to a EUR amount type-checked
95
- and returned a plausible wrong number. Both now throw `CurrencyMismatchError`
96
- on a mismatch, handle differing minor-unit exponents (USD 2 → JPY 0), and take
97
- an explicit **`FxRounding`** (`'half-away-from-zero'` default · `'floor'` ·
98
- `'ceil'`) instead of returning an unrounded float for each caller to round
99
- differently.
100
- - **`./money` — `Money.currency` is `CurrencyCode`, not `CurrencyCode | string`.**
101
- A union with `string` collapses to `string`, so the brand was decoration on
102
- the one type where it matters and `money()` validated nothing. `money()`,
103
- `fromMajor()` and `sumMoney()` now validate and brand; `isMoney` format-checks
104
- the code instead of `typeof === 'string'`. New **`currencyCode(value)`**
105
- (throwing constructor) and **`InvalidCurrencyCodeError`**.
106
- **`CurrencyMismatchError` is now DECLARED in `./currency`** (so the FX seam can
107
- throw it without an ESM cycle) and re-exported from `./money`, so existing
108
- imports are unaffected.
109
-
110
- Migration: a `Money` object literal now needs a branded code —
111
- `money(1999, 'USD')`, or `{ amount: 1999, currency: currencyCode('USD') }`.
112
-
113
- ### Changed — six silent-permissiveness defects now FAIL instead of answering
114
-
115
- Every item below previously returned a plausible-looking value where it should
116
- have errored. All are behaviour changes for malformed input only; correct input
117
- is unaffected. Falsifying tests live in `tests/unit/fail-loud-regressions.test.ts`.
118
-
119
- - **`./period` — an Invalid Date no longer reports "no overlap".** `rangesOverlap`,
120
- `isWithin` and `rangeDurationMs` throw the new `DateRangeError` on a non-finite
121
- endpoint; `isDateRange` returns `false` for one. Every comparison against `NaN`
122
- is `false`, so a corrupt range used to answer "no conflict" — the PERMISSIVE
123
- answer for the three documented consumers (order booking collision, flow
124
- reservation-window collision, promo campaign overlap), i.e. a silent double-book.
125
- - **`./currency` — the ISO 4217 exponent table is now exhaustive.** All 0-decimal
126
- (`BIF CLP DJF GNF ISK KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF`), 3-decimal
127
- (`BHD IQD JOD KWD LYD OMR TND`) and 4-decimal (`CLF UYW`) currencies are listed,
128
- so `minorUnitFactor`'s `?? 100` fallback is reachable only by a code that is not
129
- a real ISO currency. Previously `fromMajor(1500, 'GNF')` stored 150 000 minor
130
- units — a 100× error that looks like a number. New `isKnownCurrency(code)`
131
- distinguishes "listed" from "assumed" for boot-time config validation.
132
- - **`./proration` — a missing allocated part throws instead of becoming `0`.** The
133
- `?? 0` fallbacks in `splitByPeriodFraction` / `allocateMoneyByFraction` are now
134
- `ProrationError('ALLOCATION_INVARIANT')`. A zero credit and a genuinely
135
- fully-consumed period were indistinguishable downstream.
136
- - **`./state-machine` — `assertAndClaim` rejects an EMPTY `from` list.**
137
- `validSources(to)` returns `[]` for an unreachable status, and the documented
138
- `from: machine.validSources(x)` idiom then ran the assert loop zero times and
139
- handed the CAS an empty allow-list. An empty allow-list must never mean "any".
140
- - **`./timezone` — the offset regex is anchored.** An offset label the runtime
141
- emits in an unexpected shape (e.g. an unpadded `GMT+5:30`) matched the optional
142
- bare-`GMT` branch and resolved to **UTC**, silently shifting every business-day
143
- boundary derived from it. Unrecognised labels now throw; `\d{1,2}` accepts the
144
- unpadded variant rather than mis-reading it.
145
- - **`./canonical` — class instances are rejected, not collapsed.** A
146
- `Types.ObjectId`, `Buffer` or hydrated Mongoose subdocument has no own
147
- enumerable keys, so it digested as `{}` and two manifests referencing DIFFERENT
148
- documents hashed identically — the integrity check reported "unchanged".
149
- `canonicalJson` now throws `CanonicalizeError` for any object whose prototype is
150
- neither `Object.prototype` nor `null`. Plain objects, null-prototype objects,
151
- arrays and `Date` are unaffected.
152
-
153
- ### Changed — the publish smoke gate now sweeps the whole export map
154
-
155
- `tests/smoke/smoke.mjs` derives its subpath list from `package.json#exports`
156
- (44/44) instead of a hand-maintained list that covered 23, and asserts each entry
157
- declares both `types` and `default` and that both files exist. The gate runs in
158
- `prepublishOnly`; previously a subpath that lost its tsdown entry would have
159
- published an export map pointing at a missing file with the gate printing OK.
160
-
161
- ## [0.19.0] - 2026-08-01
162
-
163
- ### Added — `./monetization`: canonical pricing classification contract
164
-
165
- - **`@classytic/primitives/monetization`** — the single home for how a product
166
- is sold and priced, so the classification survives end-to-end across catalog,
167
- revenue, order, and entitlement kernels without each maintaining its own enum.
168
- - **`Monetization`** discriminated union — `FreeMonetization | OneTimeMonetization |
169
- SubscriptionMonetization | BundleMonetization | UsageMonetization`, each
170
- carrying its own nested pricing shape.
171
- - **`MONETIZATION_KINDS`** + **`MonetizationKind`** + **`isMonetizationKind`** —
172
- exhaustive kind list with runtime guard.
173
- - Type-guard predicates: **`isFreeMonetization`**, **`isOneTimeMonetization`**,
174
- **`isSubscriptionMonetization`**, **`isBundleMonetization`**,
175
- **`isUsageMonetization`**.
176
- - Supporting types: `PriceTier`, `OneTimePricing`, `SubscriptionPlan`,
177
- `UsageTier`, `UsageRating`, `DurationUnit`, `MoneyByCurrency`.
178
-
179
- ### Added — `./proration`: pure proration arithmetic
180
-
181
- - **`@classytic/primitives/proration`** — timezone-agnostic, policy-free
182
- proration math. Holds no commercial policy (no upgrade/downgrade semantics,
183
- no discount or tax logic) — those live in the contract kernel.
184
- - **`periodProgress(input)`** — fraction of a billing period consumed given
185
- start, end, and current instants as `Date`. Returns `PeriodFraction`.
186
- - **`splitByPeriodFraction(amount, fraction, granularity?)`** — split a `Money`
187
- amount into consumed/remaining parts; `granularity: 'whole_day'` (default) or
188
- `'exact'`.
189
- - **`allocateMoneyByFraction(amount, fraction)`** — proportional allocation
190
- using the largest-remainder allocator so `consumed + remaining === amount`
191
- exactly (no penny drift).
192
- - **`ProrationError`** + **`ProrationErrorCode`** (`INVALID_PERIOD` |
193
- `INVALID_FRACTION`) — typed errors for out-of-range inputs.
194
- - Supporting types: `PeriodFraction`, `PeriodProgressInput`,
195
- `ProrationGranularity`.
196
-
197
- ## [0.18.0] - 2026-07-29
198
-
199
- ### Added — `./subject`: `SubjectRef` polymorphic identity pointer
200
-
201
- - **`@classytic/primitives/subject`** — `SubjectRef` (`{ subjectModel, subjectRef }`) — WHO a record is about, polymorphically. The pairing spans every kernel that holds data about a person without owning them (attendance, access, loyalty, credentials). Deliberately kept distinct from `ExternalRef` (provenance — "came from") even though the shapes are identical: a grant carries both at once and they mean different things.
202
- - **`buildSubjectRef(model, id)`** — returns `SubjectRef | null`; `null` when `id` is absent/blank, because a malformed binding silently matches nothing (an entitlement nobody holds, a credential no revoke can find) rather than carrying `"undefined"` as a string.
203
- - **`SubjectRef`** type exported from `@classytic/primitives/subject`.
204
-
205
- ### Added — `./state-diagram`: Mermaid diagram from a `StateMachine`
206
-
207
- - **`@classytic/primitives/state-diagram`** — `renderStateDiagram(machine, opts?)` — generates a `stateDiagram-v2` Mermaid string from the same `StateMachine` object the runtime CAS enforces. Removes the class of drift where a hand-drawn prose comment diverges from the actual transition table. Pure: string in, string out, no I/O.
208
- - **`StateDiagramOptions`** — `title?`, `highlight?` (mark specific states), `noteOn?` (per-state annotations).
209
-
210
- ### Added — `./suspension`: time-bounded suspension/pause policy
211
-
212
- - **`@classytic/primitives/suspension`** — pure suspension policy computation (no persistence). Models a pause the subject is ENTITLED to take, bounded by an annual allowance, that ends by itself — distinct from `/hold` (an indefinite blocker needing manual resolution).
213
- - **`evaluateSuspension(policy, history, opts)`** — always returns `autoResumeAt` (the instant the pause MUST end) so a sweep can resume it whether or not the subject ever comes back. Covers: gym membership freeze, subscription pause, unpaid leave.
214
- - **`SuspensionPolicy`** — `maxDaysPerPeriod`, `minDurationDays`, `period` ('annual' | 'rolling').
215
- - **`SuspensionEntry`** — a historical pause record (`startedAt`, `endedAt?`, `reason?`).
216
- - **`SuspensionResult`** — `autoResumeAt`, `daysUsed`, `daysRemaining`, `status`.
217
-
218
- ### Added — `OutboxStore.transactionalSave` (`/outbox`)
219
-
220
- - **`OutboxStore.transactionalSave?: boolean`** — declares whether `save` enlists `options.session` in the caller's transaction. Absent/`false` = "assume NOT atomic". Set `true` ONLY when `save` passes the session to the same transactional resource the caller writes (e.g. a mongoose model on the same connection). Callers that need atomicity (`@classytic/access`'s `atomically()`) check this flag and fail at boot rather than claiming a guarantee they cannot keep.
221
- - **`MemoryOutboxStore.transactionalSave = false`** — explicitly declared; an in-memory store cannot participate in a Mongo transaction, so its write survives a rollback.
222
-
223
- ### Changed — `shelf-life.ts` deduplicates `addDays`
224
-
225
- - `addDays` is now imported from `./calendar` instead of being re-defined inline. No behavior change.
226
-
227
- ## [0.16.0] - 2026-07-27
228
-
229
- ### Added — `assertApproved` + `CHAIN_INCOMPLETE` (`/approval`)
230
-
231
- - **`ApprovalErrorCode` gains `'CHAIN_INCOMPLETE'`** — the single canonical
232
- error code for "finalize/commit attempted before the chain reached `approved`".
233
- Hosts route it through `rethrowApprovalError` to the `approval.chain_incomplete`
234
- wire code so all commit/post/finalize boundaries raise the same structured error.
235
- - **`assertApproved(chain, message?)`** — canonical finalize-time gate: asserts
236
- the chain has reached `approved`, else throws `ApprovalError('CHAIN_INCOMPLETE')`.
237
- A `null`/absent chain passes (no chain required). Use at every journal-entry
238
- post, PO approve, transfer dispatch, etc. to replace per-domain hand-rolled
239
- divergent codes with one definition.
240
-
241
- Purely additive — no existing exports changed.
242
-
243
- ## [0.15.0] - 2026-07-25
244
-
245
- ### Added — `/retention`: `PurgeEvidence` value object
246
-
247
- - **New `@classytic/primitives/retention` subpath** exporting **`PurgeEvidence`**
248
- — the persisted evidence record for a GDPR / data-retention purge operation
249
- (proof of what was purged, over what scope, by whom, why, and whether
250
- analytical measures were retained). Shape: `{ id, subject: { ref, model? },
251
- scope, strategy: 'hard'|'soft'|'anonymize', measuresRetained, processed,
252
- occurredAt, actor: { ref, kind: 'user'|'system'|'service' }, reason,
253
- legalBasis?, status: 'completed'|'partial'|'failed', operationId?,
254
- startedAt?, completedAt?, results?: PurgeResourceResult[],
255
- verification?: PurgeVerificationSummary }`.
256
- - **`createPurgeEvidence(input)`** — pure builder (auto-fills `id` + `occurredAt`
257
- when absent, defaults `status` to `'completed'`). **`isPurgeEvidence(value)`**
258
- type guard + **`assertPurgeEvidence(value)`** throwing assertion.
259
-
260
- ### Added — `/canonical`: strict deterministic JSON + integrity digest
261
-
262
- - **New `@classytic/primitives/canonical` subpath** — `canonicalJson(value)`
263
- produces a deterministic string (keys sorted recursively, `Date` → `{"$date":"<iso>"}`,
264
- strict rejection of `undefined`/`NaN`/`BigInt`/`Map`/`Set`/cycles with
265
- `CanonicalizeError`). **`sha256Hex(input)`** and **`canonicalDigest(value)`**
266
- (= `sha256Hex(canonicalJson(value))`) — integrity checksums, not tamper-proof
267
- seals. Uses `node:crypto` directly (same precedent as `/otp`).
268
-
269
- ### Added — `/event-infra`: `propagateHandlerErrors` option
270
-
271
- - **`InProcessEventBusOptions.propagateHandlerErrors?: boolean`** (default
272
- `false` — existing error-isolating behavior unchanged). When `true`, `publish`
273
- runs handlers sequentially and rethrows the first handler error to the
274
- publisher — for single-consumer projection/relay lanes where a failed handler
275
- must not be silently acked. `publishMany` maps each event's failure into its
276
- `PublishManyResult` entry instead of rejecting the batch.
277
-
278
- ## [0.13.0] - 2026-07-22
279
-
280
- ### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
281
-
282
- - **`@classytic/primitives/unit-cost-rate`** — fractional per-unit monetary
283
- rates stored as scaled integers (`scaledAmount = minorPerUnit × RATE_SCALE`,
284
- `RATE_SCALE = 1_000_000`). Solves the WAC / FIFO valuation problem where
285
- dividing a total by a fractional quantity yields a sub-minor-unit rate
286
- (100 paisa ÷ 3 units = 33.333… paisa/unit) that must not lose precision.
287
- Exports:
288
- - `unitCostRate(minorPerUnit, currency)` — direct rate constructor
289
- - `unitCostRateFromTotal(totalMinor, quantity, currency)` — WAC/FIFO
290
- constructor; bigint-safe, QTY_SCALE grid for fractional quantities
291
- - `extendedAmount(rate, qty, mode?)` — the ONE rate→total rounding boundary;
292
- bigint multiplication to keep large amounts exact; default `'half-even'`
293
- (banker's rounding), `'half-up'` available for legacy parity
294
- - `rateMinorPerUnit(rate)` — display helper (fractional number, NOT for math)
295
- - `isUnitCostRate(value)` — structural type guard
296
- - `UnitCostRate<C>` interface, `UnitCostRateError`, `UnitCostRateErrorCode`,
297
- `RoundingMode`, `RATE_SCALE`
298
- - For distributing a top-down charge across lines with no lost minor unit,
299
- compose with `allocate` from `@classytic/primitives/split-allocation`.
300
-
301
- ### Changed — `/outbox` is now the canonical contract owner
302
-
303
- - **`@classytic/primitives/outbox` owns the transactional-outbox contract.**
304
- Previously this file was documented as a mirror of `@classytic/arc`'s copy
305
- ("arc is source of truth, keep bit-identical") while arc's docs said the
306
- opposite — a manual-sync drift risk. Settled: primitives owns pure
307
- cross-package contracts (events + outbox); arc owns runtime behavior
308
- (`EventOutbox` relay, stores, backoff, Fastify integration) and, from
309
- arc 2.24+, imports + re-exports this contract instead of duplicating it.
310
- - **Cross-package `instanceof` now works.** Because arc re-exports these exact
311
- `OutboxOwnershipError` / `InvalidOutboxEventError` classes, a store built
312
- against primitives throws the same class identity arc's relay catches.
313
- Under the old duplication, arc's `instanceof` checks silently missed
314
- primitives-thrown ownership errors.
315
- - No export names or signatures changed — contract surface is identical.
316
-
317
- ## [0.11.0] - 2026-07-08
318
-
319
- ### Added — eKYC vocabulary + secure OTP primitive
320
-
321
- - **`@classytic/primitives/identity-verification`** — cross-package eKYC data shapes:
322
- `IdentityDocumentType`, `VerificationCheck`, `VerificationLevel`, `SubjectKind`,
323
- `IdentitySubject`, `DocumentReference`, `IdentityProviderCapabilities`,
324
- `VerificationStatus`, `CheckResult`, `CreateVerificationParams`,
325
- `VerificationSession`, `VerificationWebhookEvent`. Pure helpers: `isVerified`,
326
- `checksSatisfy`, `deriveStatus`. The identity twin of `payment-gateway` — shapes
327
- here, contracts in engines, provider implementations anywhere. Serves `@classytic/esign`
328
- (IdentityBridge port), `@classytic/payee` (ComplianceBridge), and PSP merchant KYC.
329
- - **`@classytic/primitives/otp`** — secure OTP challenge lifecycle. Guarantees: code
330
- never persisted (only HMAC-SHA256 digest stored), constant-time `timingSafeEqual`
331
- comparison, rejection-free `randomInt` CSPRNG (no modulo bias), bounded attempt
332
- counter, TTL expiry, single-use verified state. Exports: `createOtpChallenge`,
333
- `verifyOtpChallenge`, `isOtpVerified`, `isOtpActive`. Delivery stays in
334
- `@classytic/notifications` — this primitive owns only the verify half.
335
-
336
- ## [0.9.1] - 2026-07-04
337
-
338
- ### Added
339
-
340
- - **`CURRENCY_PATTERN` exported from `/currency`** — the ISO 4217 regex
341
- (`/^[A-Z]{3}$/`) behind `isCurrencyCode` / `toCurrencyCode`. Consumers
342
- embed the SAME pattern in JSON-Schema-representable validators (zod
343
- `.regex`, Mongoose `match`, OpenAPI `pattern`) instead of hand-rolling
344
- copies — `.refine(isCurrencyCode)` validates at runtime but is dropped
345
- by `z.toJSONSchema`, silently losing the constraint from generated API
346
- docs.
347
-
348
- ## [0.9.0] - 2026-07-02
349
-
350
- ### Added — `/timezone`: IANA resolution + civil dates (the door for `/calendar`'s escape hatch)
351
-
352
- `/calendar` owns wall-clock ARITHMETIC over a fixed `UtcOffsetMinutes` and
353
- tells hosts to "resolve the offset for the instant and pass it in" — but the
354
- stack had no shared resolver, so consumers hand-rolled `Intl.DateTimeFormat`
355
- (three divergent copies found: catalog ×2, be-prod POS). `/timezone` is that
356
- resolver — zero-dep (Intl IS the ICU tz database), Temporal-shaped names for
357
- a future mechanical migration:
358
-
359
- - **`zoneOffsetMinutes(instant, zone)`** — per-instant, DST-exact offset;
360
- composes directly with `/calendar`: `startOfMonth(i, zoneOffsetMinutes(i, z))`.
361
- - **`localTimeParts(instant, zone)`** — `{ isoWeekday, hour, minute,
362
- minutesOfDay }` in the zone; what pricing windows / slot grids /
363
- working-hours checks evaluate against (canonicalizes catalog's resolvers).
364
- - **`CivilDate`** (branded `'YYYY-MM-DD'`) + `civilDate` / `isCivilDate` /
365
- `civilDateOf(instant, zone)` / `civilDateToInstant(cd, zone, time?)`
366
- (two-pass DST-safe) / `addCivilDays` / `civilDaysBetween` — the type for
367
- hotel nights, POS business dates, VAT filing years. Lexicographic ==
368
- chronological, so it's a range-queryable Mongo string key.
369
- - **`isValidTimeZone(zone)`** — boot-time host-config validation;
370
- **`listTimeZones()`** + **`zoneOffsetLabel(instant, zone)`** — the data
371
- source + label for Google-Calendar-style zone pickers, straight from ICU
372
- (`Intl.supportedValuesOf`), no bundled list to go stale.
373
- - `TimeZoneError`; formatter instances cached per zone.
374
-
375
- No arithmetic here (that stays in `/calendar`) — resolution only.
376
-
377
- ## [0.7.2] - 2026-05-30
378
-
379
- ### Added — `BankTransaction` per-account routing + pending lifecycle fields
380
-
381
- Three additive optional fields on `BankTransaction` (`/bank-transaction`):
382
-
383
- - **`sourceAccountId?: string`** — the vendor-side account this row belongs to
384
- (Plaid `account_id`, Xero `BankAccount.AccountID`). Sync feeds return
385
- transactions spanning multiple accounts in one batch; this lets consumers
386
- route each row to the right account per-transaction (multi-account sync)
387
- instead of forcing the whole batch onto one account. Statement formats
388
- (single-account files) leave it undefined — the account lives on
389
- `BankStatement.account`.
390
- - **`pending?: boolean`** — the entry hasn't posted/cleared yet (real-time
391
- feeds like Plaid `pending: true`; statement formats leave it undefined).
392
- Consumers must treat pending rows as provisional — don't reconcile/post
393
- them as final.
394
- - **`supersedesExternalId?: string`** — set on a POSTED row that replaces an
395
- earlier PENDING one, carrying the pending row's `externalId` (Plaid
396
- `pending_transaction_id`). Pending and posted rows have different
397
- `externalId`s, so consumers MUST use this back-pointer to drop/replace the
398
- superseded pending row on ingest — otherwise both persist as duplicates.
399
-
400
- Backward-compatible (both optional). Closes a fidelity gap where Plaid's
401
- pending state was dropped at the canonical boundary, so downstream consumers
402
- (`@classytic/fin-io` Plaid mapper, `@classytic/revenue` import) couldn't
403
- distinguish provisional rows or dedupe pending→posted.
404
-
405
- ## [0.7.1] - 2026-05-26
406
-
407
- ### Added — workflow + scheduling + composition primitives
408
-
409
- Four new flat subpaths (additive):
410
-
411
- - **`/status-history`** — append-only status transition log primitive.
412
- - **`/condition`** — declarative condition expressions for workflow gates.
413
- - **`/mixin`** — composition helpers for primitive object assembly.
414
- - **`/sla-policy`** — SLA policy definitions paired with the existing `/sla` runtime.
415
-
416
- ### Added — payment event coverage
417
-
418
- Expanded `PAYMENT_EVENT_TYPE` catalogue to cover auth/capture and dispute
419
- lifecycles: `AUTHORIZED`, `CAPTURED`, `AUTH_VOIDED`, `DISPUTED`,
420
- `DISPUTE_WON`, `DISPUTE_LOST`, `SETTLED`.
421
-
422
- `PaymentRefundedPayload` and `PaymentReversedPayload` now carry
423
- `originalAmount` (and `reversedAmount` / `isPartial` on reversed) so
424
- consumers can detect partial vs full operations without a follow-up
425
- Payment lookup.
426
-
427
- ## [0.7.0] - 2026-05-26
428
-
429
- ### Added — payment domain primitives
430
-
431
- Three new subpaths shared across every payment-aware package
432
- (`@classytic/invoice`, `@classytic/revenue`, `@classytic/order`, future
433
- POS / AP / subscription packages). Each ships under a flat subpath so
434
- consumers tree-shake what they don't import.
435
-
436
- - **`/payment-method-kind`** — `PaymentMethodKind` universal vocabulary
437
- (kind vs host-registered `methodCode`) so packages can speak `kind`
438
- while hosts speak `code`.
439
- - **`/payment-allocation-status`** — orthogonal allocation +
440
- reconciliation status enums for payment legs (`unallocated`,
441
- `partial`, `allocated`, `over_allocated`, plus reconciliation states).
442
- - **`/payment-events`** — pure TS payload contracts for payment domain
443
- events. Wire-only (no Zod, no runtime). Anchored on `Money` +
444
- `PaymentMethodKind`; causation/correlation stay on `EventMeta`.
445
-
446
- No breaking changes.
447
-
448
- ## [0.6.0] - 2026-05-17
449
-
450
- ### Added — five new primitives for CRM / workflow domains
451
-
452
- Five new primitives covering gaps the existing surface didn't address.
453
- Each ships under its own flat subpath import (no breaking changes).
454
-
455
- - **`/phone`** — `PhoneNumber` value object + `parsePhone(input)` that
456
- normalizes free-form input to E.164, captures the country calling code
457
- as a discrete field, and reports failures via `Result<PhoneNumber,
458
- PhoneError>`. Zero external deps; ITU-T E.164 prefixes inlined. Hosts
459
- that want region-of-issue / line-type detection layer on top with
460
- libphonenumber-js.
461
-
462
- - **`/status-history`** — Generic, immutable status-change log:
463
- `StatusChangeEntry<TStatus>`, `appendStatus()`, `timeInStatus()`,
464
- `lastTransitionTo()`. Captures `durationInPriorMs` at write time so
465
- funnel-velocity dashboards ("avg time in Qualification") work without
466
- re-walking timestamps. Pairs naturally with `/state-machine`.
467
-
468
- - **`/condition`** — Tiny JSON-serializable predicate DSL: `FieldCondition`
469
- (`{ field, op, value }`) composed via `AllCondition` / `AnyCondition`
470
- / `NotCondition`, plus a total `evaluate(condition, target)` and a
471
- `validateCondition()` for boot-time schema checks. Powers SLA scope
472
- filters, drip "if opened/clicked" branches, automation triggers, and
473
- permission row-scopes — anywhere a predicate needs to round-trip
474
- through a database.
475
-
476
- - **`/mixin`** — Additive type composition without subclassing:
477
- `withMixin<T, K, M>()` / `getMixin()` / `hasMixin()` / `withoutMixin()`.
478
- Lets a `Contact` carry per-domain payloads (`mixins.customer`,
479
- `mixins.lead-fit`) on a typed `mixins` namespace without bloating the
480
- base type. Plain object composition — no decorators, no global
481
- hierarchy, no proxies.
482
-
483
- - **`/sla-policy`** — Higher-order SLA layered on the existing `/sla`:
484
- priority matrix (`urgent` / `high` / `normal` → durations), first-
485
- response vs rolling-response semantics, and working-hours window
486
- (weekdays + start/end minute + holidays). `defineSLAPolicy()` validates
487
- at boot; `evaluateSLAStatus()` produces `{ kind, responseBy,
488
- remainingMs, breached }` from a policy + per-entity inputs. The
489
- existing simple `/sla` interface is unchanged — `sla-policy` derives
490
- concrete `SLA` instances from a policy + priority and delegates to it.
491
-
492
- ### Changed — source-tree reorganization (no public-surface impact)
493
-
494
- The 24 primitive source files moved from a flat `src/*.ts` layout into
495
- six category folders so the package is self-documenting at the file
496
- system level:
497
-
498
- ```
499
- src/
500
- money/ money, currency, split-allocation, bank-transaction, payment-gateway
501
- identity/ person, address, phone
502
- scheduling/ period, cadence, sla, sla-policy
503
- workflow/ state-machine, status-history, approval, hold, condition
504
- events/ events, outbox
505
- composition/ mixin, reference, context, brand, result
506
- ```
507
-
508
- **Public subpath imports are unchanged** — `@classytic/primitives/money`,
509
- `@classytic/primitives/sla`, etc. still resolve. tsdown emits each entry
510
- to `dist/<name>.mjs` regardless of source nesting. Existing consumers
511
- upgrade with `npm install @classytic/primitives@^0.6.0` and a no-op
512
- diff.
513
-
514
- ### Dependencies
515
-
516
- Still zero runtime deps. Node 22+.
517
-
518
- ## [0.5.0] - 2026-05-05
519
-
520
- ### Added — `/bank-transaction` subpath
521
-
522
- Canonical bank-feed shapes shared across `@classytic/fin-io` (parsers
523
- produce these), `@classytic/revenue` (`transactionRepository.import()`
524
- consumes them), and `@classytic/ledger` (`bankStatementMapper`
525
- consumes them). Single source of truth for the cross-package
526
- vocabulary; eliminates the structural mirrors fin-io and revenue
527
- were carrying.
528
-
529
- Exports: `BankTransaction`, `BankCounterparty`, `BankAccount`,
530
- `BankStatement`, `BankStatementSource`, `BankImportReport`,
531
- `BankImportRowError`. All anchored on `Money` from `/money` (integer
532
- minor units, `number`).
533
-
534
- ### Added — `/payment-gateway` subpath
535
-
536
- Canonical payment-gateway data shapes. **Lets any provider package
537
- (Stripe, Razorpay, SSLCommerz, PayPal, bKash, Nagad, manual, …)
538
- implement against primitives' types without depending on revenue's
539
- heavyweight runtime.** Provider authors peer-dep on
540
- `@classytic/primitives` only.
541
-
542
- Exports: `CreateIntentParams`, `PaymentIntent`, `PaymentResult`,
543
- `RefundResult`, `WebhookEvent`, `ProviderCapabilities`. All `amount`
544
- fields are `Money` from `/money` (no flat `amount: number, currency:
545
- string` pairs — currency travels inside Money).
546
-
547
- The `PaymentProvider` abstract class — the *contract* every provider
548
- must satisfy — stays in `@classytic/revenue`. Revenue is the data
549
- engine that consumes providers; provider packages live elsewhere.
550
-
551
- ### Architectural intent
552
-
553
- Revenue 3.0 + primitives 0.5 + fin-io 0.3 establish a clean
554
- three-tier seam:
555
-
556
- 1. **primitives** — pure data shapes (no runtime, no deps). Owns
557
- `Money`, `BankTransaction`, `PaymentIntent`, every cross-
558
- package vocabulary type.
559
- 2. **revenue** — the engine. Owns the abstract contracts
560
- (`PaymentProvider`, `BankFeedProvider`), state machines, repos.
561
- Consumes primitives shapes.
562
- 3. **provider packages** (`revenue-stripe`, `revenue-razorpay`,
563
- `@classytic/fin-io/adapters/revenue`, …) — implementations.
564
- Peer-dep on primitives. Optional peer-dep on revenue for the
565
- abstract base class.
566
-
567
- This eliminates the cycle where every provider package transitively
568
- pulled mongoose + mongokit just to know what a `PaymentIntent`
569
- looked like. Tree-shaking, build times, and dep-graph honesty all
570
- improve.
571
-
572
- ### Migration
573
-
574
- Consumers MUST import from primitives subpaths. No re-exports from
575
- revenue or fin-io (PACKAGE_RULES P2):
576
-
577
- ```ts
578
- // Before
579
- import type { PaymentIntent, CreateIntentParams } from '@classytic/revenue';
580
- import type { CanonicalTransaction, Money } from '@classytic/fin-io';
581
-
582
- // After
583
- import type {
584
- PaymentIntent,
585
- CreateIntentParams,
586
- } from '@classytic/primitives/payment-gateway';
587
- import type { BankTransaction } from '@classytic/primitives/bank-transaction';
588
- import type { Money } from '@classytic/primitives/money';
589
- ```
590
-
591
- Provider helper classes (`PaymentIntent`, `PaymentResult`,
592
- `RefundResult`, `WebhookEvent`) are deleted from revenue — they were
593
- trivial wrappers around plain objects. Provider implementations now
594
- return plain object literals matching the interface.
595
-
596
- `CreateIntentParams.amount` is now `Money` (`{ amount: number,
597
- currency: string }`), not the flat `amount + currency` pair. Revenue's
598
- repository wraps internally:
599
-
600
- ```ts
601
- const intent = await provider.createIntent({
602
- amount: { amount: params.amount, currency },
603
- metadata: params.metadata,
604
- ...params.paymentData,
605
- });
606
- ```
607
-
608
- ## [0.3.1] - 2026-05-02
609
-
610
- ### Added — state-machine ↔ mongokit/sqlitekit integration
611
-
612
- State-machine primitives now compose directly with the kit-level CAS surface shipped in mongokit 3.13 and the sqlitekit follow-up. Three additions:
613
-
614
- - **`StateMachine.validTargets(from)`** — every status the aggregate can move TO from `from`. Useful for UI dropdowns ("which actions are legal right now?") and for fanning a single source state into a multi-source CAS via a repo's `claim({ from: machine.validTargets(current), to })`.
615
- - **`StateMachine.validSources(to)`** — every status that can transition INTO `to` (reverse adjacency). Pair with a repo's multi-source `claim({ from: machine.validSources(to), to })` when the target is fixed but the caller doesn't yet know the current state — the canonical "cancel from any non-terminal" / "error from any in-flight" pattern. Pre-computed at definition time, O(1) lookup, frozen arrays safe to expose by reference.
616
- - **`assertAndClaim(machine, repo, id, args)`** — pairs `machine.assertTransition` (sync domain check) with `repo.claim()` (atomic CAS) in one call. The canonical state-machine-backed CAS pattern. Skipping either layer leaves a hole: skip `assertTransition` and bad transitions reach storage; skip `claim` and concurrent writers race.
617
-
618
- ```ts
619
- import { defineStateMachine, assertAndClaim } from '@classytic/primitives/state-machine';
620
-
621
- const ORDER_MACHINE = defineStateMachine<OrderStatus>({
622
- name: 'Order',
623
- transitions: {
624
- draft: ['approved', 'cancelled'],
625
- approved: ['shipped', 'cancelled'],
626
- shipped: [],
627
- cancelled: [],
628
- },
629
- });
630
-
631
- // Single-source:
632
- const updated = await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
633
- from: 'draft',
634
- to: 'approved',
635
- patch: { approvedAt: new Date() },
636
- options: { organizationId: ctx.organizationId, session },
637
- });
638
-
639
- // Multi-source via reverse adjacency — "cancel from any legal predecessor":
640
- await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
641
- from: ORDER_MACHINE.validSources('cancelled'), // ['draft', 'approved']
642
- to: 'cancelled',
643
- patch: { cancelledAt: new Date() },
644
- options: { organizationId: ctx.organizationId },
645
- });
646
- ```
647
-
648
- #### `ClaimableRepo<TDoc>` — structural dependency on the kit
649
-
650
- `assertAndClaim` accepts any object matching `ClaimableRepo<TDoc>` — a structural type that mirrors the mongokit 3.13 `Repository.claim()` signature. Primitives doesn't take a hard peer dep on mongokit; consumers wire the kit they want. Sqlitekit's `claim()` matches the same shape (verified by mongokit/sqlitekit cross-conformance).
651
-
652
- ```ts
653
- export interface ClaimableRepo<TDoc> {
654
- claim(
655
- id: string,
656
- transition: {
657
- field?: string;
658
- from: unknown | readonly unknown[];
659
- to: unknown;
660
- where?: Record<string, unknown>;
661
- },
662
- patch?: Record<string, unknown>,
663
- options?: Record<string, unknown>,
664
- ): Promise<TDoc | null>;
665
- }
666
- ```
667
-
668
- #### Why a 0.3.1 (not 0.4.0)
669
-
670
- Purely additive — no removals, no signature changes. `defineStateMachine` callers from 0.3.0 see two new methods on the returned `StateMachine` (`validTargets`, `validSources`) and one new free function (`assertAndClaim`). Backwards-compatible.
671
-
672
- ## [0.3.0] - 2026-04-29
673
-
674
- ### Removed — `/errors` subpath (BREAKING)
675
-
676
- - Deleted `@classytic/primitives/errors` (`ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`).
677
- - Errors are infrastructure-shaped (HTTP-coupled, repository-coupled, RFC 7807-shaped). Moved to `@classytic/repo-core/errors` where they sit next to `pagination`, `tenant`, `cache`, `hooks`, `schema` — every other repository contract — and join the existing `HttpError` throwable contract there. Single canonical home for both the wire (`ErrorContract`) and throwable (`HttpError`) shapes.
678
-
679
- #### Migration
680
-
681
- ```diff
682
- - import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/primitives/errors';
683
- - import { ERROR_CODES } from '@classytic/primitives/errors';
684
- + import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/repo-core/errors';
685
- + import { ERROR_CODES } from '@classytic/repo-core/errors';
686
- ```
687
-
688
- Field shapes and code values are unchanged — pure relocation.
689
-
690
- ### Removed — `/tenant` subpath (BREAKING)
691
-
692
- - Deleted `@classytic/primitives/tenant` (`TenantConfig`, `TenantStrategy`, `TenantFieldType`, `DEFAULT_TENANT_CONFIG`, `resolveTenantConfig`, `ResolvedTenantConfig`).
693
- - Tenant scope is **infrastructure-shaped** (describes how queries get scoped to a database / repository), not a domain primitive like Money or Address. Moved to `@classytic/repo-core/tenant` where it sits next to `context`, `filter`, `hooks`, `schema`, `cache` — every other repository contract.
694
- - Mongokit and sqlitekit already peer-dep `@classytic/repo-core`, so consuming `TenantConfig` from there costs zero extra peer deps for those kits.
695
-
696
- #### Migration
697
-
698
- ```diff
699
- - import type { TenantConfig } from '@classytic/primitives/tenant';
700
- - import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/primitives/tenant';
701
- + import type { TenantConfig } from '@classytic/repo-core/tenant';
702
- + import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/repo-core/tenant';
703
- ```
704
-
705
- Field shapes, runtime semantics, and defaults are unchanged. The custom-strategy escape hatch (`strategy: 'custom'` + `resolve: (ctx) => filterShape`) works identically.
706
-
707
- ### Removed — `/pagination` subpath (BREAKING)
708
-
709
- - Deleted `@classytic/primitives/pagination` (`PageParams`, `KeysetParams`, `SortSpec`, `SortDirection`, `OffsetPage<T>`, `KeysetPage<T>`, `AggregatePage<T>`, `emptyOffsetPage`, `emptyKeysetPage`).
710
- - Pagination shapes are owned by **`@classytic/repo-core/pagination`** — that package ships both the types AND the cursor encoding/skip-math/keyset-validation algorithms. Primitives' duplicate type-only declarations existed before the contract was canonised in repo-core; keeping them invited drift.
711
-
712
- ### Migration
713
-
714
- ```diff
715
- - import type { OffsetPage, KeysetPage, AggregatePage, SortSpec } from '@classytic/primitives/pagination';
716
- + import type {
717
- + OffsetPaginationResult,
718
- + KeysetPaginationResult,
719
- + AggregatePaginationResult,
720
- + SortSpec,
721
- + } from '@classytic/repo-core/pagination';
722
- ```
723
-
724
- Note: shape names also changed (`OffsetPage` → `OffsetPaginationResult`, etc.) to match repo-core's naming. Field names align with what mongokit, sqlitekit, and arc already produced — those packages always returned repo-core shapes; primitives' types were a parallel, never-quite-aligned vocabulary.
725
-
726
- If you also need HTTP wire envelopes (`{ success: true } & Result`), repo-core 0.3.0 ships `OffsetPaginationResponse`, `KeysetPaginationResponse`, `AggregatePaginationResponse`, `BareListResponse`, `PaginatedResponse`, plus a `toCanonicalList()` runtime normalizer.
727
-
728
- ### Why now (and why this is the only breaking change in this release)
729
-
730
- Two layers of pagination shapes drifted across the org: primitives declared one set, repo-core declared a different set, mongokit + sqlitekit + arc all consumed repo-core's. Removing primitives' set is purely subtractive — nobody internally imports from `@classytic/primitives/pagination`, and external consumers get a clear migration path to repo-core. No new ground to break later.
731
-
732
- ## [0.2.0]
733
-
734
- ### Added
735
-
736
- - **`/state-machine` subpath** — `defineStateMachine<TStatus>({ name, transitions, errorFactory? })`
737
- declarative state-transition primitive. Replaces hand-rolled
738
- `if (status === 'X') throw` blocks across kernels:
739
-
740
- ```typescript
741
- import { defineStateMachine } from '@classytic/primitives/state-machine';
742
-
743
- const ORDER_MACHINE = defineStateMachine<OrderStatus>({
744
- name: 'Order',
745
- transitions: {
746
- draft: ['approved', 'cancelled'],
747
- approved: ['shipped', 'cancelled'],
748
- shipped: [],
749
- cancelled: [],
750
- },
751
- });
752
-
753
- ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
754
- ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
755
- ORDER_MACHINE.canTransition('draft', 'shipped'); // false
756
- ORDER_MACHINE.isTerminal('shipped'); // true
757
- ```
758
-
759
- Generic over the status string-union — `Record<TStatus, readonly TStatus[]>`
760
- on `transitions` enforces exhaustiveness at the call site. Kernels with their
761
- own typed transition error class (e.g. flow's `InvalidTransitionError`) wire
762
- it via `errorFactory` so the thrown type stays domain-consistent. Standalone
763
- callers get the bundled `IllegalTransitionError` (carries `entityType` /
764
- `entityId` / `from` / `to` + `code: 'illegal_transition'` + `status: 422`).
765
-
766
- First consumer: `@classytic/flow` `PROCUREMENT_MACHINE` (procurement-order
767
- approve / cancel transitions).
768
-
769
- ## [0.1.1]
770
-
771
- ### Added
772
-
773
- - **`MinorUnits<C>` branded type on `/money`** — wire-format monetary amount
774
- (integer minor units, branded with phantom currency) for HTTP/JSON fields
775
- where a full `Money` struct is overkill. Erases at runtime; `MinorUnits<'BDT'>`
776
- vs `MinorUnits<'USD'>` collide at compile time. Pair with structured `Money`
777
- for in-process arithmetic. Constructor: `MinorUnits(amount, currency?)`.
778
-
779
- ### Changed
780
-
781
- - **`/tenant` `contextKey` cascade default** — `resolveTenantConfig({ tenantField: 'branchId' })`
782
- now defaults `contextKey` to `'branchId'` instead of `'organizationId'`.
783
- When a host renames `tenantField`, their `OperationContext` carries the id
784
- under the same key in the overwhelming majority of cases; mirroring the
785
- rename is the least-surprise default. Explicit `contextKey` still overrides
786
- (set when the doc field and context key genuinely diverge). Existing call
787
- sites that supplied both `tenantField` and `contextKey` are unaffected.
788
-
789
- ## [0.1.0]
790
-
791
- Initial release. Framework-agnostic TypeScript primitives shared across
792
- Classytic packages. Zero runtime dependencies. Node 22+. ESM only.
793
- 260 tests (unit + integration) across 20 files.
794
-
795
- All subpaths are importable independently; there is **no root barrel
796
- re-export** — consumers must use subpath imports (`@classytic/primitives/money`,
797
- `/events`, `/tenant`, …) to keep the Node ESM loader + TypeScript compiler
798
- from walking the full module graph on any convenience import.
799
-
800
- ### Subpaths
801
-
802
- - **`/money`** — `Money` type + integer-minor-unit arithmetic (`money`,
803
- `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `negateMoney`,
804
- `absMoney`, `compareMoney`, `equalsMoney`, `isZeroMoney`, `isPositiveMoney`,
805
- `isNegativeMoney`, `isMoney`, `fromMajor`, `toMajor`); `CurrencyMismatchError`.
806
- - **`/currency`** — `CurrencyCode` brand; `CURRENCIES` table; `MINOR_UNIT_FACTOR`,
807
- `minorUnitFactor`, `isCurrencyCode`, `toCurrencyCode`.
808
- - **`/address`** — `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`;
809
- `toGeoJsonPoint`, `fromGeoJsonPoint`.
810
- - **`/period`** — `DateRange`, `Period`; `isDateRange`, `isWithin`,
811
- `rangeDurationMs`.
812
- - **`/pagination`** — `PageParams`, `OffsetPage<T>`, `KeysetPage<T>`,
813
- `AggregatePage`, `KeysetParams`, `SortSpec`, `SortDirection`;
814
- `emptyOffsetPage`, `emptyKeysetPage`.
815
- - **`/reference`** — `ExternalRef`, `DocumentRef`, `ObjectIdLike`, `IdLike`;
816
- `idToString`, `isExternalRef`.
817
- - **`/context`** — `OperationContext` (the identity + tracing bag every
818
- Classytic package accepts); `ActorRef`.
819
- - **`/events`** — `EventMeta` (arc v2.9-aligned — 13 fields including
820
- `schemaVersion`, `causationId`, `partitionKey`, `source`, `idempotencyKey`,
821
- `aggregate`), `DomainEvent<T>`, `EventTransport`, `EventHandler`,
822
- `EventLogger`, `DeadLetteredEvent<T>`, `PublishManyResult`; `createEvent`,
823
- `createChildEvent` (auto-chains `causationId`, inherits `correlationId` /
824
- `userId` / `organizationId` / `source` / `idempotencyKey` — NOT
825
- `aggregate`), `matchEventPattern`.
826
- - **`/outbox`** — transactional outbox **contract** mirrored from arc v2.9:
827
- `OutboxStore`, `OutboxWriteOptions`, `OutboxClaimOptions`,
828
- `OutboxAcknowledgeOptions`, `OutboxFailOptions`, `OutboxErrorInfo`,
829
- `OutboxFailureContext`, `OutboxFailureDecision`, `OutboxFailurePolicy`;
830
- `OutboxOwnershipError`, `InvalidOutboxEventError`. **Contract only** —
831
- runtime (`EventOutbox`, `MemoryOutboxStore`, `MongoOutboxStore`,
832
- `exponentialBackoff`) ships in `@classytic/arc/events` and
833
- `@classytic/arc/events/mongo`. Domain packages implement `OutboxStore`
834
- without peer-depping arc.
835
- - **`/tenant`** — `TenantConfig` (with `strategy: 'field' | 'none' | 'custom'`
836
- and optional `resolve(ctx)` for custom strategy), `TenantFieldType`,
837
- `TenantStrategy`, `ResolvedTenantConfig`; `DEFAULT_TENANT_CONFIG`,
838
- `resolveTenantConfig`. Field names match `@classytic/mongokit`'s
839
- `MultiTenantOptions` so a resolved config threads into
840
- `multiTenantPlugin({ ... })` without translation.
841
- - **`/result`** — `Result<T, E>`; `ok`, `err`, `isOk`, `isErr`, `unwrap`,
842
- `mapResult`, `mapError`.
843
- - **`/brand`** — `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`,
844
- `RequireKeys`, `OptionalKeys`, `NonEmptyArray`.
845
- - **`/errors`** — `ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`.
846
- - **`/split-allocation`** — `allocate(total, subjects, method)` returns a
847
- `SplitResult` whose parts sum exactly to `total` via the largest-remainder
848
- method. Methods: `equal`, `by-qty`, `by-weight`, `by-volume`, `by-value`,
849
- `by-percent`. Deterministic — identical inputs produce identical outputs;
850
- ties broken by input order. Handles negative totals by sign-flipping.
851
- Exports: `allocate`, `isBalanced`, `SplitAllocationError`, types
852
- `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult`,
853
- `SplitAllocationErrorCode`.
854
- - **`/approval`** — `ApprovalChain` value object with pure FSM helpers.
855
- Supports sequential and parallel chains, per-step quorum
856
- (`requiredApprovals`), threshold-gated skips. Any step-level rejection
857
- rejects the chain; all steps `approved`|`skipped` approves it. Exports:
858
- `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`,
859
- `pendingSteps`, `isApproved`, `isRejected`, `isPending`, `decisionCount`,
860
- `ApprovalError`, types `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`,
861
- `Approver`, `ApprovalStatus`, `ChainOrder`, `ApprovalThreshold`,
862
- `CreateChainInput`, `CreateStepInput`, `DecisionInput`, `ApprovalErrorCode`.
863
- - **`/cadence`** — recurrence spec with pure UTC date arithmetic. Kinds:
864
- `daily`, `weekly`, `monthly`, `yearly`, `cron` (opaque — host parses).
865
- `dayOfMonth: 31` snaps to the last day of shorter months; Feb 29 snaps
866
- to Feb 28 in non-leap years. Exports: `nextOccurrence`,
867
- `occurrencesBetween`, `validateCadence`, `CadenceError`, types `Cadence`,
868
- `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`,
869
- `CronCadence`, `CadenceKind`, `IsoWeekday`, `CadenceErrorCode`.
870
- - **`/hold`** — `HoldReason` + collection helpers. Host embeds
871
- `HoldReason[]` on documents. Exports: `addHold`, `resolveHold`,
872
- `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`,
873
- `HoldError`, types `HoldReason`, `HoldActor`, `HoldActorKind`,
874
- `AddHoldInput`, `ResolveHoldInput`, `HoldErrorCode`.
875
- - **`/sla`** — target duration + breach-detection arithmetic. Policy is
876
- carried but not executed — host reads `breachPolicy` (`warn` | `escalate`
877
- | `block`) and acts. Exports: `breachedAt`, `remainingMs`, `elapsedMs`,
878
- `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, types
879
- `SLA`, `BreachPolicy`, `SLAErrorCode`.
880
- - **`/person`** — person/contact shapes shared across order, crm, hr.
881
- `PersonName`, `ContactInfo`, `EmergencyContact`, `Gender`; formatters
882
- `formatFullName`, `formatDisplayName`.
883
-
884
- ### Design principles
885
-
886
- - Pure functions on plain data. No I/O, no Mongo, no events.
887
- - Immutable return values. Helpers return new collections; originals are never mutated.
888
- - Deterministic. Same inputs → same outputs. Clock sources injected
889
- explicitly so tests don't flake.
890
- - Errors are typed. Every module ships an `<Name>Error` class with a
891
- discriminated `code: <Name>ErrorCode` string union.
892
- - Subpath-imports only — no root barrel.
893
-
894
- ### Ownership direction
895
-
896
- Arc is the source of truth for event types. Primitives **mirrors** arc
897
- (`@classytic/arc/events/EventTransport.ts`, `@classytic/arc/events/outbox.ts`)
898
- verbatim for `EventMeta`, `DomainEvent`, `EventTransport`,
899
- `DeadLetteredEvent`, `EventLogger`, `PublishManyResult`, and the full
900
- `OutboxStore` contract surface. Helpers (`createEvent`, `createChildEvent`)
901
- are allowed to be more defensive but match arc's signatures.
902
-
903
- Packages that peer-dep `@classytic/primitives` can write
904
- `class FooOutboxRepository implements OutboxStore` without pulling arc into
905
- their dependency graph; hosts import `EventOutbox` + `MongoOutboxStore` from
906
- arc and wire them against package-provided stores.
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.22.0] - 2026-08-12
7
+
8
+ ### Added — `./period`: `shiftPeriod(period, n)` — calendar arithmetic without instants
9
+
10
+ Shift a MONTH or QUARTER period by `n` of its own units (negative = back); YEAR shifts by years; a `custom` (ad-hoc start/end) period has no unit to shift and throws rather than guessing one. No `Date` is constructed and no zone is consulted — shifting a month is pure `(year, month)` arithmetic, and the `timezone` rides along unchanged so the shifted period still resolves against the calendar it was defined in.
11
+
12
+ Exists because both inline shapes callers reach for are wrong the same way: `new Date(Date.UTC(y, m - 1 - n, 1))` answers a business-calendar question with a UTC instant (six hours off at both ends in Asia/Dhaka — the bug class this module's header documents five instances of), and hand-rolled `y * 12 + (m - 1) - n` integer math is a third copy of a rule `Period` already owns, with the year rollover exactly where copies go wrong. First consumer: be-prod `business-date.ts` ("two filing months ago").
13
+
14
+ **Versioning note:** this export briefly existed in a working copy labelled 0.21.0 after 0.21.0 had already been published without it — same version, different surface. 0.22.0 restores the invariant that a version number names exactly one contract; `>=0.22.0` is the correct floor for any consumer importing `shiftPeriod`.
15
+
16
+ ### Docs — `./environment` docblock refinement (no API change)
17
+
18
+ The 0.21.0 entry below originally listed `isProduction` / `isDevelopment` / `isTest`; the published surface is `classifyEnv`, `EnvClass`, `isProductionEnv` only — `isProductionEnv` is deliberately the single predicate (consumers needing all three buckets build their own triple from `classifyEnv`). The entry below is corrected in place; runtime and declarations are byte-identical to published 0.21.0 apart from comments.
19
+
20
+ ## [0.21.0] - 2026-08-10
21
+
22
+ ### Added — `./environment`: canonical `NODE_ENV` classification
23
+
24
+ `classifyEnv(raw)` — total function mapping a raw `NODE_ENV` string to `EnvClass = 'development' | 'test' | 'production'`. Every input yields exactly one bucket; no fourth state or `undefined`. Handles both spellings per bucket (`production`/`prod`, `test`/`qa`), trims whitespace (`.env` files and CI variables routinely carry trailing newlines), and defaults unknown values to `development` — the safe-fail direction that keeps local tooling functional.
25
+
26
+ Collapses four copies across the codebase that each missed at least one spelling: `be-prod classifyEnv`, `gym businessMode()`, arc's CLI config template (`z.preprocess`), and arc's four raw comparison sites. The missed spellings were all in the dangerous direction — a `NODE_ENV=prod` instance that matched nothing fell through to `development`, opening security gates (`Secure` cookie flag, arc's dev preset, non-transactional money writes, raw 500 messages to the client) in production.
27
+
28
+ Exports: `classifyEnv`, `EnvClass`, `isProductionEnv`.
29
+
30
+ ### Added — `publint` gate on build
31
+
32
+ `publint: true` in `tsdown.config.ts` — catches export-map entries pointing at non-existent dist files at build time rather than at consumer install time.
33
+
34
+ ## [0.20.0] - 2026-08-05
35
+
36
+ ### Added — `./period` OWNS timezone-aware period resolution (was two copies above the kernels)
37
+
38
+ `Period` was `{ year, month?, quarter? }` with no timezone and no resolver, so it
39
+ could not express "the business month of August in Asia/Dhaka". The resolver
40
+ existed TWICE, both ABOVE the kernels — `@spinekit/kit/period`
41
+ (`createBusinessCalendar`) and be-prod's `#lib/utils/business-date` — which meant
42
+ a kernel could reach neither and re-derived the boundary inline with `Date.UTC`.
43
+ Five silent UTC-month defects came out of that (`aggregateMonthlyVat`, the
44
+ sales-fact reconciler, and three in bd-tax Mushak).
45
+
46
+ This is a MOVE of the proven `createBusinessCalendar` logic, not a re-derivation:
47
+ same civil-date round-trip through `/timezone`, same half-open ranges, same
48
+ DST-exactness.
49
+
50
+ - **`Period.timezone?`** — the IANA zone a period is DEFINED in, so a persisted or
51
+ transported period keeps its own calendar.
52
+ - **`resolvePeriod(period, timezone?)`** → half-open `DateRange`. Throws when no
53
+ zone is available anywhere (never silently answers in UTC) and throws when the
54
+ period's zone and the caller's zone DISAGREE.
55
+ - **`resolveDay` / `resolveMonth` / `resolveQuarter` / `resolveYear` /
56
+ `resolveDateSpan` / `dayStart`** — the granularity-specific forms. All return
57
+ HALF-OPEN `[start, end)`, which tiles exactly.
58
+ - **`periodOf(instant, granularity, timezone)`** — the inverse: which filing
59
+ period an instant belongs to on the LOCAL calendar.
60
+ - **`parsePeriod` / `formatPeriod` / `granularityOf` / `periodTimeZone`** —
61
+ `'YYYY'` · `'YYYY-MM'` · `'YYYY-Qn'`. `parsePeriod` REFUSES a `'YYYY-MM-DD'`
62
+ rather than reading it as its month (a ~30× widening).
63
+ - **`inclusiveEnd(range)`** — `end - 1ms` for legacy `$lte` call sites, derived
64
+ from the resolved end so it stays DST-exact.
65
+ - **`PeriodError`** + **`PeriodErrorCode`** (`AMBIGUOUS_PERIOD` ·
66
+ `INCOMPLETE_RANGE` · `INVALID_FIELD` · `MISSING_TIMEZONE` · `TIMEZONE_CONFLICT`
67
+ · `INVALID_LABEL`).
68
+
69
+ A `Period` carrying two resolution shapes (`month` + `quarter`, or either with
70
+ `start`/`end`) now throws instead of preferring one.
71
+
72
+ ### Added — `./idempotency`: the claim + lease CONTRACT
73
+
74
+ Three kernels hand-rolled incompatible versions of one state machine — `cart`
75
+ (`in_flight`/`succeeded`/`failed`, the only one with crash-window leasing),
76
+ `catalog` offers (`pending`/`completed`, no lease), `contract` amendments
77
+ (implicit). Types + PURE helpers only; no persistence, no collection, no driver.
78
+
79
+ - **`ClaimOutcome = 'claimed' | 'replayed' | 'in_flight'`** — three-valued, and
80
+ `in_flight` is NOT a failure. Mapping a live concurrent lease onto an error
81
+ licenses a retry, which is a double-apply.
82
+ - **`IdempotencyClaim`** (`identity` · `requestFingerprint` · `state` ·
83
+ `leaseToken` · `leaseExpiresAt` · `attempts` · `result`), **`ClaimResult`**,
84
+ **`ClaimState`**, **`IdempotencyIdentity`**.
85
+ - **`decideClaim(existing, request)`** — the whole state machine as one pure
86
+ function. Throws `FINGERPRINT_MISMATCH` (same key, different body) and
87
+ `MISSING_REPLAY` (terminal record with no stored result) rather than answering.
88
+ - **`completeClaim` / `renewLease`** — both refuse when the caller lost its lease,
89
+ so a superseded attempt cannot overwrite the winner's result.
90
+ - **`identityKey`** (canonical-JSON composite, so a delimiter inside a scope value
91
+ cannot collide two identities), **`fingerprintRequest`**, **`newLeaseToken`**,
92
+ **`isLeaseExpired`**, **`holdsLease`**, **`isInFlight`**, **`DEFAULT_LEASE_MS`**.
93
+
94
+ ### Changed — BREAKING: five API decisions taken before spine wiring starts
95
+
96
+ - **`./cadence` — `Cadence.timezone` is now AUTHORITATIVE, not display-only.**
97
+ It was documented as decorative while `stepMonthly`/`stepYearly` used
98
+ `getUTCMonth()`/`getUTCDate()`, so `timezone: 'Asia/Dhaka'` + `dayOfMonth: 1`
99
+ with a midnight anchor placed every occurrence on the local **2nd**. All four
100
+ kinds now run on the zone's wall clock when one is set (daily advances LOCAL
101
+ days — 23h/25h across a DST transition), and stay UTC when it is absent.
102
+ `validateCadence` rejects an unresolvable zone with the new
103
+ `INVALID_TIMEZONE`. **Behavioural change for any persisted cadence that
104
+ already carries a `timezone`** — the occurrences move to where the field
105
+ always claimed they were.
106
+ - **`./approval` — `assertApproved` no longer passes on a `null` chain.** It now
107
+ throws the new `CHAIN_MISSING`. Absence is not approval, and a mongoose schema
108
+ that never declared `approvalChain` strips it on write, so the gate could not
109
+ tell "no approval configured" from "approval silently lost". The explicit
110
+ opt-out is the new **`assertApprovedIfPresent`**. The second parameter is now
111
+ an options object (`{ message }`), not a bare string.
112
+ - **`./payment-events` — every payload carries an `eventType` discriminant, and
113
+ there is a `payment.unknown` event.** `PaymentInitiatedPayload` and
114
+ `PaymentSucceededPayload` were structurally IDENTICAL with no discriminant on
115
+ the union, so a handler typed for "funds received" accepted "money in flight".
116
+ Adds **`PaymentUnknownPayload`** (the port contract's third outcome had no
117
+ event, so an unobserved result had to be forced into SUCCEEDED or FAILED),
118
+ **`PaymentEventPayloadMap`**, **`isPaymentEvent`**, **`isFundsReceived`**, and
119
+ three compile-time drift guards over the map.
120
+ - **`./currency` — `convertWithSnapshot` / `reverseWithSnapshot` take and return
121
+ `Money`.** A bare `number` meant nothing checked the amount was denominated in
122
+ `fx.sourceCurrency`; a USD→BDT snapshot applied to a EUR amount type-checked
123
+ and returned a plausible wrong number. Both now throw `CurrencyMismatchError`
124
+ on a mismatch, handle differing minor-unit exponents (USD 2 → JPY 0), and take
125
+ an explicit **`FxRounding`** (`'half-away-from-zero'` default · `'floor'` ·
126
+ `'ceil'`) instead of returning an unrounded float for each caller to round
127
+ differently.
128
+ - **`./money` — `Money.currency` is `CurrencyCode`, not `CurrencyCode | string`.**
129
+ A union with `string` collapses to `string`, so the brand was decoration on
130
+ the one type where it matters and `money()` validated nothing. `money()`,
131
+ `fromMajor()` and `sumMoney()` now validate and brand; `isMoney` format-checks
132
+ the code instead of `typeof === 'string'`. New **`currencyCode(value)`**
133
+ (throwing constructor) and **`InvalidCurrencyCodeError`**.
134
+ **`CurrencyMismatchError` is now DECLARED in `./currency`** (so the FX seam can
135
+ throw it without an ESM cycle) and re-exported from `./money`, so existing
136
+ imports are unaffected.
137
+
138
+ Migration: a `Money` object literal now needs a branded code —
139
+ `money(1999, 'USD')`, or `{ amount: 1999, currency: currencyCode('USD') }`.
140
+
141
+ ### Changed — six silent-permissiveness defects now FAIL instead of answering
142
+
143
+ Every item below previously returned a plausible-looking value where it should
144
+ have errored. All are behaviour changes for malformed input only; correct input
145
+ is unaffected. Falsifying tests live in `tests/unit/fail-loud-regressions.test.ts`.
146
+
147
+ - **`./period` — an Invalid Date no longer reports "no overlap".** `rangesOverlap`,
148
+ `isWithin` and `rangeDurationMs` throw the new `DateRangeError` on a non-finite
149
+ endpoint; `isDateRange` returns `false` for one. Every comparison against `NaN`
150
+ is `false`, so a corrupt range used to answer "no conflict" — the PERMISSIVE
151
+ answer for the three documented consumers (order booking collision, flow
152
+ reservation-window collision, promo campaign overlap), i.e. a silent double-book.
153
+ - **`./currency` — the ISO 4217 exponent table is now exhaustive.** All 0-decimal
154
+ (`BIF CLP DJF GNF ISK KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF`), 3-decimal
155
+ (`BHD IQD JOD KWD LYD OMR TND`) and 4-decimal (`CLF UYW`) currencies are listed,
156
+ so `minorUnitFactor`'s `?? 100` fallback is reachable only by a code that is not
157
+ a real ISO currency. Previously `fromMajor(1500, 'GNF')` stored 150 000 minor
158
+ units — a 100× error that looks like a number. New `isKnownCurrency(code)`
159
+ distinguishes "listed" from "assumed" for boot-time config validation.
160
+ - **`./proration` — a missing allocated part throws instead of becoming `0`.** The
161
+ `?? 0` fallbacks in `splitByPeriodFraction` / `allocateMoneyByFraction` are now
162
+ `ProrationError('ALLOCATION_INVARIANT')`. A zero credit and a genuinely
163
+ fully-consumed period were indistinguishable downstream.
164
+ - **`./state-machine` — `assertAndClaim` rejects an EMPTY `from` list.**
165
+ `validSources(to)` returns `[]` for an unreachable status, and the documented
166
+ `from: machine.validSources(x)` idiom then ran the assert loop zero times and
167
+ handed the CAS an empty allow-list. An empty allow-list must never mean "any".
168
+ - **`./timezone` — the offset regex is anchored.** An offset label the runtime
169
+ emits in an unexpected shape (e.g. an unpadded `GMT+5:30`) matched the optional
170
+ bare-`GMT` branch and resolved to **UTC**, silently shifting every business-day
171
+ boundary derived from it. Unrecognised labels now throw; `\d{1,2}` accepts the
172
+ unpadded variant rather than mis-reading it.
173
+ - **`./canonical` — class instances are rejected, not collapsed.** A
174
+ `Types.ObjectId`, `Buffer` or hydrated Mongoose subdocument has no own
175
+ enumerable keys, so it digested as `{}` and two manifests referencing DIFFERENT
176
+ documents hashed identically — the integrity check reported "unchanged".
177
+ `canonicalJson` now throws `CanonicalizeError` for any object whose prototype is
178
+ neither `Object.prototype` nor `null`. Plain objects, null-prototype objects,
179
+ arrays and `Date` are unaffected.
180
+
181
+ ### Changed — the publish smoke gate now sweeps the whole export map
182
+
183
+ `tests/smoke/smoke.mjs` derives its subpath list from `package.json#exports`
184
+ (44/44) instead of a hand-maintained list that covered 23, and asserts each entry
185
+ declares both `types` and `default` and that both files exist. The gate runs in
186
+ `prepublishOnly`; previously a subpath that lost its tsdown entry would have
187
+ published an export map pointing at a missing file with the gate printing OK.
188
+
189
+ ## [0.19.0] - 2026-08-01
190
+
191
+ ### Added — `./monetization`: canonical pricing classification contract
192
+
193
+ - **`@classytic/primitives/monetization`** — the single home for how a product
194
+ is sold and priced, so the classification survives end-to-end across catalog,
195
+ revenue, order, and entitlement kernels without each maintaining its own enum.
196
+ - **`Monetization`** discriminated union — `FreeMonetization | OneTimeMonetization |
197
+ SubscriptionMonetization | BundleMonetization | UsageMonetization`, each
198
+ carrying its own nested pricing shape.
199
+ - **`MONETIZATION_KINDS`** + **`MonetizationKind`** + **`isMonetizationKind`** —
200
+ exhaustive kind list with runtime guard.
201
+ - Type-guard predicates: **`isFreeMonetization`**, **`isOneTimeMonetization`**,
202
+ **`isSubscriptionMonetization`**, **`isBundleMonetization`**,
203
+ **`isUsageMonetization`**.
204
+ - Supporting types: `PriceTier`, `OneTimePricing`, `SubscriptionPlan`,
205
+ `UsageTier`, `UsageRating`, `DurationUnit`, `MoneyByCurrency`.
206
+
207
+ ### Added — `./proration`: pure proration arithmetic
208
+
209
+ - **`@classytic/primitives/proration`** — timezone-agnostic, policy-free
210
+ proration math. Holds no commercial policy (no upgrade/downgrade semantics,
211
+ no discount or tax logic) — those live in the contract kernel.
212
+ - **`periodProgress(input)`** — fraction of a billing period consumed given
213
+ start, end, and current instants as `Date`. Returns `PeriodFraction`.
214
+ - **`splitByPeriodFraction(amount, fraction, granularity?)`** — split a `Money`
215
+ amount into consumed/remaining parts; `granularity: 'whole_day'` (default) or
216
+ `'exact'`.
217
+ - **`allocateMoneyByFraction(amount, fraction)`** — proportional allocation
218
+ using the largest-remainder allocator so `consumed + remaining === amount`
219
+ exactly (no penny drift).
220
+ - **`ProrationError`** + **`ProrationErrorCode`** (`INVALID_PERIOD` |
221
+ `INVALID_FRACTION`) — typed errors for out-of-range inputs.
222
+ - Supporting types: `PeriodFraction`, `PeriodProgressInput`,
223
+ `ProrationGranularity`.
224
+
225
+ ## [0.18.0] - 2026-07-29
226
+
227
+ ### Added — `./subject`: `SubjectRef` polymorphic identity pointer
228
+
229
+ - **`@classytic/primitives/subject`** — `SubjectRef` (`{ subjectModel, subjectRef }`) — WHO a record is about, polymorphically. The pairing spans every kernel that holds data about a person without owning them (attendance, access, loyalty, credentials). Deliberately kept distinct from `ExternalRef` (provenance — "came from") even though the shapes are identical: a grant carries both at once and they mean different things.
230
+ - **`buildSubjectRef(model, id)`** — returns `SubjectRef | null`; `null` when `id` is absent/blank, because a malformed binding silently matches nothing (an entitlement nobody holds, a credential no revoke can find) rather than carrying `"undefined"` as a string.
231
+ - **`SubjectRef`** type exported from `@classytic/primitives/subject`.
232
+
233
+ ### Added — `./state-diagram`: Mermaid diagram from a `StateMachine`
234
+
235
+ - **`@classytic/primitives/state-diagram`** — `renderStateDiagram(machine, opts?)` — generates a `stateDiagram-v2` Mermaid string from the same `StateMachine` object the runtime CAS enforces. Removes the class of drift where a hand-drawn prose comment diverges from the actual transition table. Pure: string in, string out, no I/O.
236
+ - **`StateDiagramOptions`** — `title?`, `highlight?` (mark specific states), `noteOn?` (per-state annotations).
237
+
238
+ ### Added — `./suspension`: time-bounded suspension/pause policy
239
+
240
+ - **`@classytic/primitives/suspension`** — pure suspension policy computation (no persistence). Models a pause the subject is ENTITLED to take, bounded by an annual allowance, that ends by itself — distinct from `/hold` (an indefinite blocker needing manual resolution).
241
+ - **`evaluateSuspension(policy, history, opts)`** — always returns `autoResumeAt` (the instant the pause MUST end) so a sweep can resume it whether or not the subject ever comes back. Covers: gym membership freeze, subscription pause, unpaid leave.
242
+ - **`SuspensionPolicy`** — `maxDaysPerPeriod`, `minDurationDays`, `period` ('annual' | 'rolling').
243
+ - **`SuspensionEntry`** — a historical pause record (`startedAt`, `endedAt?`, `reason?`).
244
+ - **`SuspensionResult`** — `autoResumeAt`, `daysUsed`, `daysRemaining`, `status`.
245
+
246
+ ### Added — `OutboxStore.transactionalSave` (`/outbox`)
247
+
248
+ - **`OutboxStore.transactionalSave?: boolean`** — declares whether `save` enlists `options.session` in the caller's transaction. Absent/`false` = "assume NOT atomic". Set `true` ONLY when `save` passes the session to the same transactional resource the caller writes (e.g. a mongoose model on the same connection). Callers that need atomicity (`@classytic/access`'s `atomically()`) check this flag and fail at boot rather than claiming a guarantee they cannot keep.
249
+ - **`MemoryOutboxStore.transactionalSave = false`** — explicitly declared; an in-memory store cannot participate in a Mongo transaction, so its write survives a rollback.
250
+
251
+ ### Changed — `shelf-life.ts` deduplicates `addDays`
252
+
253
+ - `addDays` is now imported from `./calendar` instead of being re-defined inline. No behavior change.
254
+
255
+ ## [0.16.0] - 2026-07-27
256
+
257
+ ### Added — `assertApproved` + `CHAIN_INCOMPLETE` (`/approval`)
258
+
259
+ - **`ApprovalErrorCode` gains `'CHAIN_INCOMPLETE'`** — the single canonical
260
+ error code for "finalize/commit attempted before the chain reached `approved`".
261
+ Hosts route it through `rethrowApprovalError` to the `approval.chain_incomplete`
262
+ wire code so all commit/post/finalize boundaries raise the same structured error.
263
+ - **`assertApproved(chain, message?)`** — canonical finalize-time gate: asserts
264
+ the chain has reached `approved`, else throws `ApprovalError('CHAIN_INCOMPLETE')`.
265
+ A `null`/absent chain passes (no chain required). Use at every journal-entry
266
+ post, PO approve, transfer dispatch, etc. to replace per-domain hand-rolled
267
+ divergent codes with one definition.
268
+
269
+ Purely additive — no existing exports changed.
270
+
271
+ ## [0.15.0] - 2026-07-25
272
+
273
+ ### Added — `/retention`: `PurgeEvidence` value object
274
+
275
+ - **New `@classytic/primitives/retention` subpath** exporting **`PurgeEvidence`**
276
+ — the persisted evidence record for a GDPR / data-retention purge operation
277
+ (proof of what was purged, over what scope, by whom, why, and whether
278
+ analytical measures were retained). Shape: `{ id, subject: { ref, model? },
279
+ scope, strategy: 'hard'|'soft'|'anonymize', measuresRetained, processed,
280
+ occurredAt, actor: { ref, kind: 'user'|'system'|'service' }, reason,
281
+ legalBasis?, status: 'completed'|'partial'|'failed', operationId?,
282
+ startedAt?, completedAt?, results?: PurgeResourceResult[],
283
+ verification?: PurgeVerificationSummary }`.
284
+ - **`createPurgeEvidence(input)`** — pure builder (auto-fills `id` + `occurredAt`
285
+ when absent, defaults `status` to `'completed'`). **`isPurgeEvidence(value)`**
286
+ type guard + **`assertPurgeEvidence(value)`** throwing assertion.
287
+
288
+ ### Added — `/canonical`: strict deterministic JSON + integrity digest
289
+
290
+ - **New `@classytic/primitives/canonical` subpath** — `canonicalJson(value)`
291
+ produces a deterministic string (keys sorted recursively, `Date` → `{"$date":"<iso>"}`,
292
+ strict rejection of `undefined`/`NaN`/`BigInt`/`Map`/`Set`/cycles with
293
+ `CanonicalizeError`). **`sha256Hex(input)`** and **`canonicalDigest(value)`**
294
+ (= `sha256Hex(canonicalJson(value))`) — integrity checksums, not tamper-proof
295
+ seals. Uses `node:crypto` directly (same precedent as `/otp`).
296
+
297
+ ### Added — `/event-infra`: `propagateHandlerErrors` option
298
+
299
+ - **`InProcessEventBusOptions.propagateHandlerErrors?: boolean`** (default
300
+ `false` — existing error-isolating behavior unchanged). When `true`, `publish`
301
+ runs handlers sequentially and rethrows the first handler error to the
302
+ publisher — for single-consumer projection/relay lanes where a failed handler
303
+ must not be silently acked. `publishMany` maps each event's failure into its
304
+ `PublishManyResult` entry instead of rejecting the batch.
305
+
306
+ ## [0.13.0] - 2026-07-22
307
+
308
+ ### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
309
+
310
+ - **`@classytic/primitives/unit-cost-rate`** — fractional per-unit monetary
311
+ rates stored as scaled integers (`scaledAmount = minorPerUnit × RATE_SCALE`,
312
+ `RATE_SCALE = 1_000_000`). Solves the WAC / FIFO valuation problem where
313
+ dividing a total by a fractional quantity yields a sub-minor-unit rate
314
+ (100 paisa ÷ 3 units = 33.333… paisa/unit) that must not lose precision.
315
+ Exports:
316
+ - `unitCostRate(minorPerUnit, currency)` — direct rate constructor
317
+ - `unitCostRateFromTotal(totalMinor, quantity, currency)` — WAC/FIFO
318
+ constructor; bigint-safe, QTY_SCALE grid for fractional quantities
319
+ - `extendedAmount(rate, qty, mode?)` — the ONE rate→total rounding boundary;
320
+ bigint multiplication to keep large amounts exact; default `'half-even'`
321
+ (banker's rounding), `'half-up'` available for legacy parity
322
+ - `rateMinorPerUnit(rate)` — display helper (fractional number, NOT for math)
323
+ - `isUnitCostRate(value)` — structural type guard
324
+ - `UnitCostRate<C>` interface, `UnitCostRateError`, `UnitCostRateErrorCode`,
325
+ `RoundingMode`, `RATE_SCALE`
326
+ - For distributing a top-down charge across lines with no lost minor unit,
327
+ compose with `allocate` from `@classytic/primitives/split-allocation`.
328
+
329
+ ### Changed — `/outbox` is now the canonical contract owner
330
+
331
+ - **`@classytic/primitives/outbox` owns the transactional-outbox contract.**
332
+ Previously this file was documented as a mirror of `@classytic/arc`'s copy
333
+ ("arc is source of truth, keep bit-identical") while arc's docs said the
334
+ opposite — a manual-sync drift risk. Settled: primitives owns pure
335
+ cross-package contracts (events + outbox); arc owns runtime behavior
336
+ (`EventOutbox` relay, stores, backoff, Fastify integration) and, from
337
+ arc 2.24+, imports + re-exports this contract instead of duplicating it.
338
+ - **Cross-package `instanceof` now works.** Because arc re-exports these exact
339
+ `OutboxOwnershipError` / `InvalidOutboxEventError` classes, a store built
340
+ against primitives throws the same class identity arc's relay catches.
341
+ Under the old duplication, arc's `instanceof` checks silently missed
342
+ primitives-thrown ownership errors.
343
+ - No export names or signatures changed — contract surface is identical.
344
+
345
+ ## [0.11.0] - 2026-07-08
346
+
347
+ ### Added — eKYC vocabulary + secure OTP primitive
348
+
349
+ - **`@classytic/primitives/identity-verification`** — cross-package eKYC data shapes:
350
+ `IdentityDocumentType`, `VerificationCheck`, `VerificationLevel`, `SubjectKind`,
351
+ `IdentitySubject`, `DocumentReference`, `IdentityProviderCapabilities`,
352
+ `VerificationStatus`, `CheckResult`, `CreateVerificationParams`,
353
+ `VerificationSession`, `VerificationWebhookEvent`. Pure helpers: `isVerified`,
354
+ `checksSatisfy`, `deriveStatus`. The identity twin of `payment-gateway` — shapes
355
+ here, contracts in engines, provider implementations anywhere. Serves `@classytic/esign`
356
+ (IdentityBridge port), `@classytic/payee` (ComplianceBridge), and PSP merchant KYC.
357
+ - **`@classytic/primitives/otp`** — secure OTP challenge lifecycle. Guarantees: code
358
+ never persisted (only HMAC-SHA256 digest stored), constant-time `timingSafeEqual`
359
+ comparison, rejection-free `randomInt` CSPRNG (no modulo bias), bounded attempt
360
+ counter, TTL expiry, single-use verified state. Exports: `createOtpChallenge`,
361
+ `verifyOtpChallenge`, `isOtpVerified`, `isOtpActive`. Delivery stays in
362
+ `@classytic/notifications` — this primitive owns only the verify half.
363
+
364
+ ## [0.9.1] - 2026-07-04
365
+
366
+ ### Added
367
+
368
+ - **`CURRENCY_PATTERN` exported from `/currency`** — the ISO 4217 regex
369
+ (`/^[A-Z]{3}$/`) behind `isCurrencyCode` / `toCurrencyCode`. Consumers
370
+ embed the SAME pattern in JSON-Schema-representable validators (zod
371
+ `.regex`, Mongoose `match`, OpenAPI `pattern`) instead of hand-rolling
372
+ copies — `.refine(isCurrencyCode)` validates at runtime but is dropped
373
+ by `z.toJSONSchema`, silently losing the constraint from generated API
374
+ docs.
375
+
376
+ ## [0.9.0] - 2026-07-02
377
+
378
+ ### Added — `/timezone`: IANA resolution + civil dates (the door for `/calendar`'s escape hatch)
379
+
380
+ `/calendar` owns wall-clock ARITHMETIC over a fixed `UtcOffsetMinutes` and
381
+ tells hosts to "resolve the offset for the instant and pass it in" — but the
382
+ stack had no shared resolver, so consumers hand-rolled `Intl.DateTimeFormat`
383
+ (three divergent copies found: catalog ×2, be-prod POS). `/timezone` is that
384
+ resolver — zero-dep (Intl IS the ICU tz database), Temporal-shaped names for
385
+ a future mechanical migration:
386
+
387
+ - **`zoneOffsetMinutes(instant, zone)`** — per-instant, DST-exact offset;
388
+ composes directly with `/calendar`: `startOfMonth(i, zoneOffsetMinutes(i, z))`.
389
+ - **`localTimeParts(instant, zone)`** — `{ isoWeekday, hour, minute,
390
+ minutesOfDay }` in the zone; what pricing windows / slot grids /
391
+ working-hours checks evaluate against (canonicalizes catalog's resolvers).
392
+ - **`CivilDate`** (branded `'YYYY-MM-DD'`) + `civilDate` / `isCivilDate` /
393
+ `civilDateOf(instant, zone)` / `civilDateToInstant(cd, zone, time?)`
394
+ (two-pass DST-safe) / `addCivilDays` / `civilDaysBetween` — the type for
395
+ hotel nights, POS business dates, VAT filing years. Lexicographic ==
396
+ chronological, so it's a range-queryable Mongo string key.
397
+ - **`isValidTimeZone(zone)`** — boot-time host-config validation;
398
+ **`listTimeZones()`** + **`zoneOffsetLabel(instant, zone)`** — the data
399
+ source + label for Google-Calendar-style zone pickers, straight from ICU
400
+ (`Intl.supportedValuesOf`), no bundled list to go stale.
401
+ - `TimeZoneError`; formatter instances cached per zone.
402
+
403
+ No arithmetic here (that stays in `/calendar`) — resolution only.
404
+
405
+ ## [0.7.2] - 2026-05-30
406
+
407
+ ### Added — `BankTransaction` per-account routing + pending lifecycle fields
408
+
409
+ Three additive optional fields on `BankTransaction` (`/bank-transaction`):
410
+
411
+ - **`sourceAccountId?: string`** — the vendor-side account this row belongs to
412
+ (Plaid `account_id`, Xero `BankAccount.AccountID`). Sync feeds return
413
+ transactions spanning multiple accounts in one batch; this lets consumers
414
+ route each row to the right account per-transaction (multi-account sync)
415
+ instead of forcing the whole batch onto one account. Statement formats
416
+ (single-account files) leave it undefined — the account lives on
417
+ `BankStatement.account`.
418
+ - **`pending?: boolean`** — the entry hasn't posted/cleared yet (real-time
419
+ feeds like Plaid `pending: true`; statement formats leave it undefined).
420
+ Consumers must treat pending rows as provisional — don't reconcile/post
421
+ them as final.
422
+ - **`supersedesExternalId?: string`** — set on a POSTED row that replaces an
423
+ earlier PENDING one, carrying the pending row's `externalId` (Plaid
424
+ `pending_transaction_id`). Pending and posted rows have different
425
+ `externalId`s, so consumers MUST use this back-pointer to drop/replace the
426
+ superseded pending row on ingest — otherwise both persist as duplicates.
427
+
428
+ Backward-compatible (both optional). Closes a fidelity gap where Plaid's
429
+ pending state was dropped at the canonical boundary, so downstream consumers
430
+ (`@classytic/fin-io` Plaid mapper, `@classytic/revenue` import) couldn't
431
+ distinguish provisional rows or dedupe pending→posted.
432
+
433
+ ## [0.7.1] - 2026-05-26
434
+
435
+ ### Added — workflow + scheduling + composition primitives
436
+
437
+ Four new flat subpaths (additive):
438
+
439
+ - **`/status-history`** — append-only status transition log primitive.
440
+ - **`/condition`** — declarative condition expressions for workflow gates.
441
+ - **`/mixin`** — composition helpers for primitive object assembly.
442
+ - **`/sla-policy`** — SLA policy definitions paired with the existing `/sla` runtime.
443
+
444
+ ### Added — payment event coverage
445
+
446
+ Expanded `PAYMENT_EVENT_TYPE` catalogue to cover auth/capture and dispute
447
+ lifecycles: `AUTHORIZED`, `CAPTURED`, `AUTH_VOIDED`, `DISPUTED`,
448
+ `DISPUTE_WON`, `DISPUTE_LOST`, `SETTLED`.
449
+
450
+ `PaymentRefundedPayload` and `PaymentReversedPayload` now carry
451
+ `originalAmount` (and `reversedAmount` / `isPartial` on reversed) so
452
+ consumers can detect partial vs full operations without a follow-up
453
+ Payment lookup.
454
+
455
+ ## [0.7.0] - 2026-05-26
456
+
457
+ ### Added — payment domain primitives
458
+
459
+ Three new subpaths shared across every payment-aware package
460
+ (`@classytic/invoice`, `@classytic/revenue`, `@classytic/order`, future
461
+ POS / AP / subscription packages). Each ships under a flat subpath so
462
+ consumers tree-shake what they don't import.
463
+
464
+ - **`/payment-method-kind`** — `PaymentMethodKind` universal vocabulary
465
+ (kind vs host-registered `methodCode`) so packages can speak `kind`
466
+ while hosts speak `code`.
467
+ - **`/payment-allocation-status`** — orthogonal allocation +
468
+ reconciliation status enums for payment legs (`unallocated`,
469
+ `partial`, `allocated`, `over_allocated`, plus reconciliation states).
470
+ - **`/payment-events`** — pure TS payload contracts for payment domain
471
+ events. Wire-only (no Zod, no runtime). Anchored on `Money` +
472
+ `PaymentMethodKind`; causation/correlation stay on `EventMeta`.
473
+
474
+ No breaking changes.
475
+
476
+ ## [0.6.0] - 2026-05-17
477
+
478
+ ### Added — five new primitives for CRM / workflow domains
479
+
480
+ Five new primitives covering gaps the existing surface didn't address.
481
+ Each ships under its own flat subpath import (no breaking changes).
482
+
483
+ - **`/phone`** — `PhoneNumber` value object + `parsePhone(input)` that
484
+ normalizes free-form input to E.164, captures the country calling code
485
+ as a discrete field, and reports failures via `Result<PhoneNumber,
486
+ PhoneError>`. Zero external deps; ITU-T E.164 prefixes inlined. Hosts
487
+ that want region-of-issue / line-type detection layer on top with
488
+ libphonenumber-js.
489
+
490
+ - **`/status-history`** — Generic, immutable status-change log:
491
+ `StatusChangeEntry<TStatus>`, `appendStatus()`, `timeInStatus()`,
492
+ `lastTransitionTo()`. Captures `durationInPriorMs` at write time so
493
+ funnel-velocity dashboards ("avg time in Qualification") work without
494
+ re-walking timestamps. Pairs naturally with `/state-machine`.
495
+
496
+ - **`/condition`** — Tiny JSON-serializable predicate DSL: `FieldCondition`
497
+ (`{ field, op, value }`) composed via `AllCondition` / `AnyCondition`
498
+ / `NotCondition`, plus a total `evaluate(condition, target)` and a
499
+ `validateCondition()` for boot-time schema checks. Powers SLA scope
500
+ filters, drip "if opened/clicked" branches, automation triggers, and
501
+ permission row-scopes — anywhere a predicate needs to round-trip
502
+ through a database.
503
+
504
+ - **`/mixin`** — Additive type composition without subclassing:
505
+ `withMixin<T, K, M>()` / `getMixin()` / `hasMixin()` / `withoutMixin()`.
506
+ Lets a `Contact` carry per-domain payloads (`mixins.customer`,
507
+ `mixins.lead-fit`) on a typed `mixins` namespace without bloating the
508
+ base type. Plain object composition — no decorators, no global
509
+ hierarchy, no proxies.
510
+
511
+ - **`/sla-policy`** — Higher-order SLA layered on the existing `/sla`:
512
+ priority matrix (`urgent` / `high` / `normal` → durations), first-
513
+ response vs rolling-response semantics, and working-hours window
514
+ (weekdays + start/end minute + holidays). `defineSLAPolicy()` validates
515
+ at boot; `evaluateSLAStatus()` produces `{ kind, responseBy,
516
+ remainingMs, breached }` from a policy + per-entity inputs. The
517
+ existing simple `/sla` interface is unchanged — `sla-policy` derives
518
+ concrete `SLA` instances from a policy + priority and delegates to it.
519
+
520
+ ### Changed — source-tree reorganization (no public-surface impact)
521
+
522
+ The 24 primitive source files moved from a flat `src/*.ts` layout into
523
+ six category folders so the package is self-documenting at the file
524
+ system level:
525
+
526
+ ```
527
+ src/
528
+ money/ money, currency, split-allocation, bank-transaction, payment-gateway
529
+ identity/ person, address, phone
530
+ scheduling/ period, cadence, sla, sla-policy
531
+ workflow/ state-machine, status-history, approval, hold, condition
532
+ events/ events, outbox
533
+ composition/ mixin, reference, context, brand, result
534
+ ```
535
+
536
+ **Public subpath imports are unchanged** — `@classytic/primitives/money`,
537
+ `@classytic/primitives/sla`, etc. still resolve. tsdown emits each entry
538
+ to `dist/<name>.mjs` regardless of source nesting. Existing consumers
539
+ upgrade with `npm install @classytic/primitives@^0.6.0` and a no-op
540
+ diff.
541
+
542
+ ### Dependencies
543
+
544
+ Still zero runtime deps. Node 22+.
545
+
546
+ ## [0.5.0] - 2026-05-05
547
+
548
+ ### Added — `/bank-transaction` subpath
549
+
550
+ Canonical bank-feed shapes shared across `@classytic/fin-io` (parsers
551
+ produce these), `@classytic/revenue` (`transactionRepository.import()`
552
+ consumes them), and `@classytic/ledger` (`bankStatementMapper`
553
+ consumes them). Single source of truth for the cross-package
554
+ vocabulary; eliminates the structural mirrors fin-io and revenue
555
+ were carrying.
556
+
557
+ Exports: `BankTransaction`, `BankCounterparty`, `BankAccount`,
558
+ `BankStatement`, `BankStatementSource`, `BankImportReport`,
559
+ `BankImportRowError`. All anchored on `Money` from `/money` (integer
560
+ minor units, `number`).
561
+
562
+ ### Added — `/payment-gateway` subpath
563
+
564
+ Canonical payment-gateway data shapes. **Lets any provider package
565
+ (Stripe, Razorpay, SSLCommerz, PayPal, bKash, Nagad, manual, …)
566
+ implement against primitives' types without depending on revenue's
567
+ heavyweight runtime.** Provider authors peer-dep on
568
+ `@classytic/primitives` only.
569
+
570
+ Exports: `CreateIntentParams`, `PaymentIntent`, `PaymentResult`,
571
+ `RefundResult`, `WebhookEvent`, `ProviderCapabilities`. All `amount`
572
+ fields are `Money` from `/money` (no flat `amount: number, currency:
573
+ string` pairs — currency travels inside Money).
574
+
575
+ The `PaymentProvider` abstract class — the *contract* every provider
576
+ must satisfy — stays in `@classytic/revenue`. Revenue is the data
577
+ engine that consumes providers; provider packages live elsewhere.
578
+
579
+ ### Architectural intent
580
+
581
+ Revenue 3.0 + primitives 0.5 + fin-io 0.3 establish a clean
582
+ three-tier seam:
583
+
584
+ 1. **primitives** — pure data shapes (no runtime, no deps). Owns
585
+ `Money`, `BankTransaction`, `PaymentIntent`, every cross-
586
+ package vocabulary type.
587
+ 2. **revenue** — the engine. Owns the abstract contracts
588
+ (`PaymentProvider`, `BankFeedProvider`), state machines, repos.
589
+ Consumes primitives shapes.
590
+ 3. **provider packages** (`revenue-stripe`, `revenue-razorpay`,
591
+ `@classytic/fin-io/adapters/revenue`, …) — implementations.
592
+ Peer-dep on primitives. Optional peer-dep on revenue for the
593
+ abstract base class.
594
+
595
+ This eliminates the cycle where every provider package transitively
596
+ pulled mongoose + mongokit just to know what a `PaymentIntent`
597
+ looked like. Tree-shaking, build times, and dep-graph honesty all
598
+ improve.
599
+
600
+ ### Migration
601
+
602
+ Consumers MUST import from primitives subpaths. No re-exports from
603
+ revenue or fin-io (PACKAGE_RULES P2):
604
+
605
+ ```ts
606
+ // Before
607
+ import type { PaymentIntent, CreateIntentParams } from '@classytic/revenue';
608
+ import type { CanonicalTransaction, Money } from '@classytic/fin-io';
609
+
610
+ // After
611
+ import type {
612
+ PaymentIntent,
613
+ CreateIntentParams,
614
+ } from '@classytic/primitives/payment-gateway';
615
+ import type { BankTransaction } from '@classytic/primitives/bank-transaction';
616
+ import type { Money } from '@classytic/primitives/money';
617
+ ```
618
+
619
+ Provider helper classes (`PaymentIntent`, `PaymentResult`,
620
+ `RefundResult`, `WebhookEvent`) are deleted from revenue — they were
621
+ trivial wrappers around plain objects. Provider implementations now
622
+ return plain object literals matching the interface.
623
+
624
+ `CreateIntentParams.amount` is now `Money` (`{ amount: number,
625
+ currency: string }`), not the flat `amount + currency` pair. Revenue's
626
+ repository wraps internally:
627
+
628
+ ```ts
629
+ const intent = await provider.createIntent({
630
+ amount: { amount: params.amount, currency },
631
+ metadata: params.metadata,
632
+ ...params.paymentData,
633
+ });
634
+ ```
635
+
636
+ ## [0.3.1] - 2026-05-02
637
+
638
+ ### Added — state-machine ↔ mongokit/sqlitekit integration
639
+
640
+ State-machine primitives now compose directly with the kit-level CAS surface shipped in mongokit 3.13 and the sqlitekit follow-up. Three additions:
641
+
642
+ - **`StateMachine.validTargets(from)`** — every status the aggregate can move TO from `from`. Useful for UI dropdowns ("which actions are legal right now?") and for fanning a single source state into a multi-source CAS via a repo's `claim({ from: machine.validTargets(current), to })`.
643
+ - **`StateMachine.validSources(to)`** — every status that can transition INTO `to` (reverse adjacency). Pair with a repo's multi-source `claim({ from: machine.validSources(to), to })` when the target is fixed but the caller doesn't yet know the current state — the canonical "cancel from any non-terminal" / "error from any in-flight" pattern. Pre-computed at definition time, O(1) lookup, frozen arrays safe to expose by reference.
644
+ - **`assertAndClaim(machine, repo, id, args)`** — pairs `machine.assertTransition` (sync domain check) with `repo.claim()` (atomic CAS) in one call. The canonical state-machine-backed CAS pattern. Skipping either layer leaves a hole: skip `assertTransition` and bad transitions reach storage; skip `claim` and concurrent writers race.
645
+
646
+ ```ts
647
+ import { defineStateMachine, assertAndClaim } from '@classytic/primitives/state-machine';
648
+
649
+ const ORDER_MACHINE = defineStateMachine<OrderStatus>({
650
+ name: 'Order',
651
+ transitions: {
652
+ draft: ['approved', 'cancelled'],
653
+ approved: ['shipped', 'cancelled'],
654
+ shipped: [],
655
+ cancelled: [],
656
+ },
657
+ });
658
+
659
+ // Single-source:
660
+ const updated = await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
661
+ from: 'draft',
662
+ to: 'approved',
663
+ patch: { approvedAt: new Date() },
664
+ options: { organizationId: ctx.organizationId, session },
665
+ });
666
+
667
+ // Multi-source via reverse adjacency — "cancel from any legal predecessor":
668
+ await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
669
+ from: ORDER_MACHINE.validSources('cancelled'), // ['draft', 'approved']
670
+ to: 'cancelled',
671
+ patch: { cancelledAt: new Date() },
672
+ options: { organizationId: ctx.organizationId },
673
+ });
674
+ ```
675
+
676
+ #### `ClaimableRepo<TDoc>` — structural dependency on the kit
677
+
678
+ `assertAndClaim` accepts any object matching `ClaimableRepo<TDoc>` — a structural type that mirrors the mongokit 3.13 `Repository.claim()` signature. Primitives doesn't take a hard peer dep on mongokit; consumers wire the kit they want. Sqlitekit's `claim()` matches the same shape (verified by mongokit/sqlitekit cross-conformance).
679
+
680
+ ```ts
681
+ export interface ClaimableRepo<TDoc> {
682
+ claim(
683
+ id: string,
684
+ transition: {
685
+ field?: string;
686
+ from: unknown | readonly unknown[];
687
+ to: unknown;
688
+ where?: Record<string, unknown>;
689
+ },
690
+ patch?: Record<string, unknown>,
691
+ options?: Record<string, unknown>,
692
+ ): Promise<TDoc | null>;
693
+ }
694
+ ```
695
+
696
+ #### Why a 0.3.1 (not 0.4.0)
697
+
698
+ Purely additive — no removals, no signature changes. `defineStateMachine` callers from 0.3.0 see two new methods on the returned `StateMachine` (`validTargets`, `validSources`) and one new free function (`assertAndClaim`). Backwards-compatible.
699
+
700
+ ## [0.3.0] - 2026-04-29
701
+
702
+ ### Removed — `/errors` subpath (BREAKING)
703
+
704
+ - Deleted `@classytic/primitives/errors` (`ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`).
705
+ - Errors are infrastructure-shaped (HTTP-coupled, repository-coupled, RFC 7807-shaped). Moved to `@classytic/repo-core/errors` where they sit next to `pagination`, `tenant`, `cache`, `hooks`, `schema` — every other repository contract — and join the existing `HttpError` throwable contract there. Single canonical home for both the wire (`ErrorContract`) and throwable (`HttpError`) shapes.
706
+
707
+ #### Migration
708
+
709
+ ```diff
710
+ - import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/primitives/errors';
711
+ - import { ERROR_CODES } from '@classytic/primitives/errors';
712
+ + import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/repo-core/errors';
713
+ + import { ERROR_CODES } from '@classytic/repo-core/errors';
714
+ ```
715
+
716
+ Field shapes and code values are unchanged — pure relocation.
717
+
718
+ ### Removed — `/tenant` subpath (BREAKING)
719
+
720
+ - Deleted `@classytic/primitives/tenant` (`TenantConfig`, `TenantStrategy`, `TenantFieldType`, `DEFAULT_TENANT_CONFIG`, `resolveTenantConfig`, `ResolvedTenantConfig`).
721
+ - Tenant scope is **infrastructure-shaped** (describes how queries get scoped to a database / repository), not a domain primitive like Money or Address. Moved to `@classytic/repo-core/tenant` where it sits next to `context`, `filter`, `hooks`, `schema`, `cache` — every other repository contract.
722
+ - Mongokit and sqlitekit already peer-dep `@classytic/repo-core`, so consuming `TenantConfig` from there costs zero extra peer deps for those kits.
723
+
724
+ #### Migration
725
+
726
+ ```diff
727
+ - import type { TenantConfig } from '@classytic/primitives/tenant';
728
+ - import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/primitives/tenant';
729
+ + import type { TenantConfig } from '@classytic/repo-core/tenant';
730
+ + import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/repo-core/tenant';
731
+ ```
732
+
733
+ Field shapes, runtime semantics, and defaults are unchanged. The custom-strategy escape hatch (`strategy: 'custom'` + `resolve: (ctx) => filterShape`) works identically.
734
+
735
+ ### Removed — `/pagination` subpath (BREAKING)
736
+
737
+ - Deleted `@classytic/primitives/pagination` (`PageParams`, `KeysetParams`, `SortSpec`, `SortDirection`, `OffsetPage<T>`, `KeysetPage<T>`, `AggregatePage<T>`, `emptyOffsetPage`, `emptyKeysetPage`).
738
+ - Pagination shapes are owned by **`@classytic/repo-core/pagination`** — that package ships both the types AND the cursor encoding/skip-math/keyset-validation algorithms. Primitives' duplicate type-only declarations existed before the contract was canonised in repo-core; keeping them invited drift.
739
+
740
+ ### Migration
741
+
742
+ ```diff
743
+ - import type { OffsetPage, KeysetPage, AggregatePage, SortSpec } from '@classytic/primitives/pagination';
744
+ + import type {
745
+ + OffsetPaginationResult,
746
+ + KeysetPaginationResult,
747
+ + AggregatePaginationResult,
748
+ + SortSpec,
749
+ + } from '@classytic/repo-core/pagination';
750
+ ```
751
+
752
+ Note: shape names also changed (`OffsetPage` → `OffsetPaginationResult`, etc.) to match repo-core's naming. Field names align with what mongokit, sqlitekit, and arc already produced — those packages always returned repo-core shapes; primitives' types were a parallel, never-quite-aligned vocabulary.
753
+
754
+ If you also need HTTP wire envelopes (`{ success: true } & Result`), repo-core 0.3.0 ships `OffsetPaginationResponse`, `KeysetPaginationResponse`, `AggregatePaginationResponse`, `BareListResponse`, `PaginatedResponse`, plus a `toCanonicalList()` runtime normalizer.
755
+
756
+ ### Why now (and why this is the only breaking change in this release)
757
+
758
+ Two layers of pagination shapes drifted across the org: primitives declared one set, repo-core declared a different set, mongokit + sqlitekit + arc all consumed repo-core's. Removing primitives' set is purely subtractive — nobody internally imports from `@classytic/primitives/pagination`, and external consumers get a clear migration path to repo-core. No new ground to break later.
759
+
760
+ ## [0.2.0]
761
+
762
+ ### Added
763
+
764
+ - **`/state-machine` subpath** — `defineStateMachine<TStatus>({ name, transitions, errorFactory? })`
765
+ declarative state-transition primitive. Replaces hand-rolled
766
+ `if (status === 'X') throw` blocks across kernels:
767
+
768
+ ```typescript
769
+ import { defineStateMachine } from '@classytic/primitives/state-machine';
770
+
771
+ const ORDER_MACHINE = defineStateMachine<OrderStatus>({
772
+ name: 'Order',
773
+ transitions: {
774
+ draft: ['approved', 'cancelled'],
775
+ approved: ['shipped', 'cancelled'],
776
+ shipped: [],
777
+ cancelled: [],
778
+ },
779
+ });
780
+
781
+ ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
782
+ ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
783
+ ORDER_MACHINE.canTransition('draft', 'shipped'); // false
784
+ ORDER_MACHINE.isTerminal('shipped'); // true
785
+ ```
786
+
787
+ Generic over the status string-union — `Record<TStatus, readonly TStatus[]>`
788
+ on `transitions` enforces exhaustiveness at the call site. Kernels with their
789
+ own typed transition error class (e.g. flow's `InvalidTransitionError`) wire
790
+ it via `errorFactory` so the thrown type stays domain-consistent. Standalone
791
+ callers get the bundled `IllegalTransitionError` (carries `entityType` /
792
+ `entityId` / `from` / `to` + `code: 'illegal_transition'` + `status: 422`).
793
+
794
+ First consumer: `@classytic/flow` `PROCUREMENT_MACHINE` (procurement-order
795
+ approve / cancel transitions).
796
+
797
+ ## [0.1.1]
798
+
799
+ ### Added
800
+
801
+ - **`MinorUnits<C>` branded type on `/money`** — wire-format monetary amount
802
+ (integer minor units, branded with phantom currency) for HTTP/JSON fields
803
+ where a full `Money` struct is overkill. Erases at runtime; `MinorUnits<'BDT'>`
804
+ vs `MinorUnits<'USD'>` collide at compile time. Pair with structured `Money`
805
+ for in-process arithmetic. Constructor: `MinorUnits(amount, currency?)`.
806
+
807
+ ### Changed
808
+
809
+ - **`/tenant` `contextKey` cascade default** — `resolveTenantConfig({ tenantField: 'branchId' })`
810
+ now defaults `contextKey` to `'branchId'` instead of `'organizationId'`.
811
+ When a host renames `tenantField`, their `OperationContext` carries the id
812
+ under the same key in the overwhelming majority of cases; mirroring the
813
+ rename is the least-surprise default. Explicit `contextKey` still overrides
814
+ (set when the doc field and context key genuinely diverge). Existing call
815
+ sites that supplied both `tenantField` and `contextKey` are unaffected.
816
+
817
+ ## [0.1.0]
818
+
819
+ Initial release. Framework-agnostic TypeScript primitives shared across
820
+ Classytic packages. Zero runtime dependencies. Node 22+. ESM only.
821
+ 260 tests (unit + integration) across 20 files.
822
+
823
+ All subpaths are importable independently; there is **no root barrel
824
+ re-export** — consumers must use subpath imports (`@classytic/primitives/money`,
825
+ `/events`, `/tenant`, …) to keep the Node ESM loader + TypeScript compiler
826
+ from walking the full module graph on any convenience import.
827
+
828
+ ### Subpaths
829
+
830
+ - **`/money`** — `Money` type + integer-minor-unit arithmetic (`money`,
831
+ `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `negateMoney`,
832
+ `absMoney`, `compareMoney`, `equalsMoney`, `isZeroMoney`, `isPositiveMoney`,
833
+ `isNegativeMoney`, `isMoney`, `fromMajor`, `toMajor`); `CurrencyMismatchError`.
834
+ - **`/currency`** — `CurrencyCode` brand; `CURRENCIES` table; `MINOR_UNIT_FACTOR`,
835
+ `minorUnitFactor`, `isCurrencyCode`, `toCurrencyCode`.
836
+ - **`/address`** — `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`;
837
+ `toGeoJsonPoint`, `fromGeoJsonPoint`.
838
+ - **`/period`** — `DateRange`, `Period`; `isDateRange`, `isWithin`,
839
+ `rangeDurationMs`.
840
+ - **`/pagination`** — `PageParams`, `OffsetPage<T>`, `KeysetPage<T>`,
841
+ `AggregatePage`, `KeysetParams`, `SortSpec`, `SortDirection`;
842
+ `emptyOffsetPage`, `emptyKeysetPage`.
843
+ - **`/reference`** — `ExternalRef`, `DocumentRef`, `ObjectIdLike`, `IdLike`;
844
+ `idToString`, `isExternalRef`.
845
+ - **`/context`** — `OperationContext` (the identity + tracing bag every
846
+ Classytic package accepts); `ActorRef`.
847
+ - **`/events`** — `EventMeta` (arc v2.9-aligned — 13 fields including
848
+ `schemaVersion`, `causationId`, `partitionKey`, `source`, `idempotencyKey`,
849
+ `aggregate`), `DomainEvent<T>`, `EventTransport`, `EventHandler`,
850
+ `EventLogger`, `DeadLetteredEvent<T>`, `PublishManyResult`; `createEvent`,
851
+ `createChildEvent` (auto-chains `causationId`, inherits `correlationId` /
852
+ `userId` / `organizationId` / `source` / `idempotencyKey` — NOT
853
+ `aggregate`), `matchEventPattern`.
854
+ - **`/outbox`** — transactional outbox **contract** mirrored from arc v2.9:
855
+ `OutboxStore`, `OutboxWriteOptions`, `OutboxClaimOptions`,
856
+ `OutboxAcknowledgeOptions`, `OutboxFailOptions`, `OutboxErrorInfo`,
857
+ `OutboxFailureContext`, `OutboxFailureDecision`, `OutboxFailurePolicy`;
858
+ `OutboxOwnershipError`, `InvalidOutboxEventError`. **Contract only** —
859
+ runtime (`EventOutbox`, `MemoryOutboxStore`, `MongoOutboxStore`,
860
+ `exponentialBackoff`) ships in `@classytic/arc/events` and
861
+ `@classytic/arc/events/mongo`. Domain packages implement `OutboxStore`
862
+ without peer-depping arc.
863
+ - **`/tenant`** — `TenantConfig` (with `strategy: 'field' | 'none' | 'custom'`
864
+ and optional `resolve(ctx)` for custom strategy), `TenantFieldType`,
865
+ `TenantStrategy`, `ResolvedTenantConfig`; `DEFAULT_TENANT_CONFIG`,
866
+ `resolveTenantConfig`. Field names match `@classytic/mongokit`'s
867
+ `MultiTenantOptions` so a resolved config threads into
868
+ `multiTenantPlugin({ ... })` without translation.
869
+ - **`/result`** — `Result<T, E>`; `ok`, `err`, `isOk`, `isErr`, `unwrap`,
870
+ `mapResult`, `mapError`.
871
+ - **`/brand`** — `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`,
872
+ `RequireKeys`, `OptionalKeys`, `NonEmptyArray`.
873
+ - **`/errors`** — `ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`.
874
+ - **`/split-allocation`** — `allocate(total, subjects, method)` returns a
875
+ `SplitResult` whose parts sum exactly to `total` via the largest-remainder
876
+ method. Methods: `equal`, `by-qty`, `by-weight`, `by-volume`, `by-value`,
877
+ `by-percent`. Deterministic — identical inputs produce identical outputs;
878
+ ties broken by input order. Handles negative totals by sign-flipping.
879
+ Exports: `allocate`, `isBalanced`, `SplitAllocationError`, types
880
+ `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult`,
881
+ `SplitAllocationErrorCode`.
882
+ - **`/approval`** — `ApprovalChain` value object with pure FSM helpers.
883
+ Supports sequential and parallel chains, per-step quorum
884
+ (`requiredApprovals`), threshold-gated skips. Any step-level rejection
885
+ rejects the chain; all steps `approved`|`skipped` approves it. Exports:
886
+ `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`,
887
+ `pendingSteps`, `isApproved`, `isRejected`, `isPending`, `decisionCount`,
888
+ `ApprovalError`, types `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`,
889
+ `Approver`, `ApprovalStatus`, `ChainOrder`, `ApprovalThreshold`,
890
+ `CreateChainInput`, `CreateStepInput`, `DecisionInput`, `ApprovalErrorCode`.
891
+ - **`/cadence`** — recurrence spec with pure UTC date arithmetic. Kinds:
892
+ `daily`, `weekly`, `monthly`, `yearly`, `cron` (opaque — host parses).
893
+ `dayOfMonth: 31` snaps to the last day of shorter months; Feb 29 snaps
894
+ to Feb 28 in non-leap years. Exports: `nextOccurrence`,
895
+ `occurrencesBetween`, `validateCadence`, `CadenceError`, types `Cadence`,
896
+ `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`,
897
+ `CronCadence`, `CadenceKind`, `IsoWeekday`, `CadenceErrorCode`.
898
+ - **`/hold`** — `HoldReason` + collection helpers. Host embeds
899
+ `HoldReason[]` on documents. Exports: `addHold`, `resolveHold`,
900
+ `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`,
901
+ `HoldError`, types `HoldReason`, `HoldActor`, `HoldActorKind`,
902
+ `AddHoldInput`, `ResolveHoldInput`, `HoldErrorCode`.
903
+ - **`/sla`** — target duration + breach-detection arithmetic. Policy is
904
+ carried but not executed — host reads `breachPolicy` (`warn` | `escalate`
905
+ | `block`) and acts. Exports: `breachedAt`, `remainingMs`, `elapsedMs`,
906
+ `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, types
907
+ `SLA`, `BreachPolicy`, `SLAErrorCode`.
908
+ - **`/person`** — person/contact shapes shared across order, crm, hr.
909
+ `PersonName`, `ContactInfo`, `EmergencyContact`, `Gender`; formatters
910
+ `formatFullName`, `formatDisplayName`.
911
+
912
+ ### Design principles
913
+
914
+ - Pure functions on plain data. No I/O, no Mongo, no events.
915
+ - Immutable return values. Helpers return new collections; originals are never mutated.
916
+ - Deterministic. Same inputs → same outputs. Clock sources injected
917
+ explicitly so tests don't flake.
918
+ - Errors are typed. Every module ships an `<Name>Error` class with a
919
+ discriminated `code: <Name>ErrorCode` string union.
920
+ - Subpath-imports only — no root barrel.
921
+
922
+ ### Ownership direction
923
+
924
+ Arc is the source of truth for event types. Primitives **mirrors** arc
925
+ (`@classytic/arc/events/EventTransport.ts`, `@classytic/arc/events/outbox.ts`)
926
+ verbatim for `EventMeta`, `DomainEvent`, `EventTransport`,
927
+ `DeadLetteredEvent`, `EventLogger`, `PublishManyResult`, and the full
928
+ `OutboxStore` contract surface. Helpers (`createEvent`, `createChildEvent`)
929
+ are allowed to be more defensive but match arc's signatures.
930
+
931
+ Packages that peer-dep `@classytic/primitives` can write
932
+ `class FooOutboxRepository implements OutboxStore` without pulling arc into
933
+ their dependency graph; hosts import `EventOutbox` + `MongoOutboxStore` from
934
+ arc and wire them against package-provided stores.