@classytic/primitives 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,1119 +1,1217 @@
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.28.0
7
-
8
- ### Added: a `pass` monetization kind (paid once, grants a term, never renews)
9
-
10
- The union could say "charged once" (`one_time`, a single price, no span) and
11
- "recurring" (`subscription`, priced spans that renew). It could not say
12
- "prepaid term": a course access pass, a gym or transit pass, a parking permit,
13
- a term licence. Hosts selling one reached for `subscription`, the only shape
14
- that carried priced spans, and then had to explain at every read that it does
15
- not actually renew. `toRevenueMonetizationType` would also have settled those
16
- sales as recurring income, forecasting revenue nothing was scheduled to bill.
17
-
18
- `PassMonetization { type: 'pass', passes: AccessPass[] }`, where an
19
- `AccessPass` is a key, a label, a price, an optional "was" price, and a
20
- `duration` + `durationUnit`. Shaped like `SubscriptionPlan` so a term means
21
- the same thing everywhere, kept separate because a plan carries `trialDays`
22
- and a renewal discount that a pass has none of.
23
-
24
- Also: `isPassMonetization`, `defaultPassOf` (the cheapest term, ties broken on
25
- the shorter SPAN so the headline is deterministic), and `approxTermDays` for
26
- ordering and labels only. `unitPriceOf` returns the cheapest term's price for
27
- a pass, `compareAtPriceOf` now returns the "was" of that SAME term so a card
28
- can never strike one term's price through beside another's, and
29
- `toRevenueMonetizationType('pass')` is `purchase`.
30
-
31
- A perpetual sale stays `one_time`: "forever" is the absence of a term, not a
32
- very long one.
33
-
34
- ### Added — `CashDestination` on `./money`
35
-
36
- `CASH_DESTINATIONS` (`'cash' | 'petty_cash'`), `isCashDestination` and
37
- `cashDestinationLabel`: WHERE received cash landed, as an intent — never a
38
- chart-of-accounts code. One vocabulary for a request body, a domain event, a
39
- client SDK and a settlement dialog, so the accounting tier alone maps it to the
40
- active chart and a renumber cannot reach any of them. Added because a client
41
- carried a literal bank code the chart had already renumbered, and every
42
- settlement it sent was refused.
43
-
44
- ## 0.27.0
45
-
46
- ### Added — `./csv` subpath: one CSV cell escaper for the whole fleet
47
-
48
- `escapeCsvCell(value)` and `neutralizeCsvFormula(value)`. Six CSV producers
49
- across ledger, the spine and the hosts each hand-rolled RFC 4180 quoting, and
50
- not one neutralized a leading `= + - @ \t \r` — so a customer-supplied payment
51
- reference or party name reached the accountant's spreadsheet as a live formula.
52
- Quoting does not help: a spreadsheet strips the quotes and evaluates what is
53
- left, which is why the two defences ship as one module.
54
-
55
- The two functions are separable on purpose. A consumer holding a real CSV
56
- writer (`csv-stringify`) takes `neutralizeCsvFormula` only, so its quoting is
57
- not done twice; a hand-rolled `rows.map(...).join(',')` takes `escapeCsvCell`.
58
-
59
- **Well-formed numbers are exempt.** Prefixing `-1500` would make every negative
60
- amount text and break `SUM` in every finance export — a fix that quietly
61
- corrupts the product is worse than the hole. The exemption requires the WHOLE
62
- cell to be a number, so `-2+3+cmd|'/c calc'!A0` is still neutralized.
63
-
64
- Not for a machine-to-machine upload contract with a fixed column spec (a
65
- courier's bulk-import CSV): there the receiver is a parser, not a spreadsheet,
66
- and an added `'` corrupts the payload.
67
-
68
- ## 0.26.4
69
-
70
- ### Added — `./price-basis` subpath: measured pricing across physical dimensions
71
-
72
- New `PriceBasis` type (`FixedPriceBasis | MeasuredPriceBasis`) models how a quoted price spans a sellable quantity — flat fixed or per-unit-of-measure (mass, duration, volume, length, area). Factory functions `fixedPriceBasis()` and `measuredPriceBasis(dimension, qtyUnit, priceUnit, qtyPerPriceUnit)` construct the two shapes; `isPriceBasis()` is a structural guard for DB/wire boundaries. `resolvePriceBasisTotal(unitPriceMinor, quantity, basis?)` converts a minor-unit price × integer quantity to a final minor-unit total using half-even rounding (`roundedDiv` internally) and throws `PriceBasisError` on unsafe inputs or overflow.
73
-
74
- ### Added — `./unit-cost-rate` re-exports `RoundingMode`
75
-
76
- `RoundingMode` (`'half-even' | 'half-up'`) is now defined in the new internal `rounded-div.ts` and re-exported from `unit-cost-rate`. No breaking change — existing consumers who import `RoundingMode` from `@classytic/primitives/unit-cost-rate` continue to work.
77
-
78
- ### Changed — `validateAddress` / `isAddressValid` accept `Address` directly
79
-
80
- Both functions now accept `Address | PartialAddress | null | undefined`, removing the need for a cast when validating a fully-typed value object.
81
-
82
- ## 0.26.3
83
-
84
- ### Added — `PaymentProviderPort.wantsRawBody`
85
-
86
- Optional `readonly wantsRawBody?: boolean` on the payment gateway port.
87
- Providers that verify an HMAC signature (Stripe signs the bytes it transmitted)
88
- must declare `true` — the transport then hands the raw body rather than the
89
- parsed object. Absent / `false` preserves the historical behaviour (parsed
90
- body), which is correct for manual, cash and terminal providers that sign
91
- nothing.
92
-
93
- ## 0.26.2
94
-
95
- ### Added — `stockKeyFor` (`/item-identity`): one stock-key precedence for every package
96
-
97
- The product-level stock key, with a single precedence:
98
-
99
- 1. the variant's stamped `skuRef` — assigned once at birth, immutable
100
- 2. a **sole** variant with no stamp → the product's `_id`, never the label
101
- (legacy writers derived `variantSku || productId`, and for one variant that
102
- key IS the `_id`)
103
- 3. a **sibling** with no stamp → its label, which genuinely is its legacy key
104
- 4. nothing addressable → the product's `_id`
105
-
106
- The caller declares only what it HAS — `{ kind: 'sole' | 'label' | 'variant' }`
107
- — so the sole-vs-sibling decision is internal and a caller cannot make it wrong.
108
- That choice previously lived at every call site across three packages, in two
109
- functions with different fallbacks; they disagreed on a sole, unstamped,
110
- labelled variant, and the read then looked up a key no quant is stored under.
111
-
112
- It lives here, below every domain, because `@spinekit/order` and
113
- `@spinekit/catalog` must not import a peer domain — the rule sitting one layer
114
- too high is exactly why each package re-derived it.
115
-
116
- ## 0.26.1
117
-
118
- - `./testing/stock-key-seam` — new subpath; `scanStockKeySeam`, `assertStockKeySeam`, `takesStockKeyFromLabel`, `STOCK_KEY_SEAM_FIXTURES` for conformance-scanning packages that resolve stock keys
119
-
120
- ## [0.26.0] — 2026-08-27
121
-
122
- ### Added
123
-
124
- - **`./deadline` subpath** — `Deadline` value type + helpers for date-bounded
125
- obligations (reservations, SLA windows, contract terms).
126
- - **`./stock-level` subpath** — canonical stock-level representation and helpers.
127
- - **`./item-facts` subpath** — cross-kernel item fact contract (weight, dimensions,
128
- hazmat flags) that catalog declares and logistics/inventory reads.
129
- - **`StockKey` branded type** (`./item-identity`) — prevents a bare merchandising
130
- label or ObjectId being passed where a quant key is expected; the only source is
131
- `stockKeyOf()` / `requireStockKey()`. Return types of both functions updated.
132
- - **Upfront collection policy** (`./monetization`) — `UpfrontCollectionPolicy`
133
- (`'full_payment' | 'deposit' | 'deferred'`) + `UPFRONT_COLLECTION_POLICIES`
134
- const. Cross-kernel: catalog declares the policy, order turns it into tenders,
135
- revenue settles them.
136
-
137
- ## [0.25.0] - 2026-08-24
138
-
139
- ### Added
140
-
141
- - **`@classytic/primitives/item-identity` subpath** — `SkuFields`, `stockKeyOf`, `skuDisplay`. `sku` is a label; `skuRef` is the stock identity — the two fall back in opposite directions and getting one backwards type-checks while returning a plausible wrong string. `stockKeyOf` resolves `skuRef ?? sku ?? fallback` (the key to reserve, decrement and post against); `skuDisplay` resolves `skuLabel ?? sku ?? skuRef` (what a human reads). Canonicalises the per-package `variantSku || String(productId)` derivations into one place.
142
-
143
- ## [0.24.0] - 2026-08-24
144
-
145
- ### Added — `@classytic/primitives/address`: capture-format system
146
-
147
- `AddressFormat`, `AddressFieldDescriptor`, `AddressFieldKey`, `PartialAddress`, `AddressFieldError` — a description of HOW a country collects a `ContactAddress`: field labels, order, requiredness, subdivision taxonomy hooks, and postal code pattern. Country packs (e.g. `@classytic/bd-areas/address-format`) live beside their taxonomies; this subpath owns only the generic shape.
148
-
149
- `validateAddress(address, format)` — validates a partial address against a format; returns `AddressFieldError[]`. Enforces `required` on both canonical and extension fields identically, and checks `postalCodePattern` only when a value is given and a pattern is declared. `isAddressValid` and `describeAddressError` are convenience wrappers.
150
-
151
- Every `key` in a descriptor is a field of `ContactAddress`; co-location with the value object is what makes a mismatch obvious instead of invisible.
152
-
153
- ## [0.23.0] - 2026-08-15
154
-
155
- ### Added — `./approval`: `notRequiredChain(reason)` + `isNotRequired(chain)`
156
-
157
- The chain for a subject that needs **no** approval — zero steps, already resolved. The
158
- explicit, greppable answer to "the policy engine ran and decided this document does not
159
- require approval", which callers previously had no way to express.
160
-
161
- This states a conclusion the module already reached rather than inventing one:
162
- `computeChainStatus` folds an empty step list to `'approved'` — "nothing outstanding" is
163
- what approved MEANS here, and that fold predates this function. `createChain` still throws
164
- `EMPTY_STEPS`: building a ROUTING chain that routes nowhere is a different mistake and
165
- stays refused. The two are deliberately not interchangeable, so reaching "approved with no
166
- approvers" requires saying so by name.
167
-
168
- **Why a chain rather than leaving the field unset.** Absence is the one input that occurs
169
- by accident — a schema that never declared `approvals` strips it on write — and
170
- `assertApproved` must keep treating that as `CHAIN_MISSING`. A present zero-step chain is
171
- distinguishable from a lost chain (absent) *and* from a granted one (has steps and
172
- decisions), so existing gates keep working through `isApproved` alone. No consumer needs a
173
- second predicate, which is what would drift.
174
-
175
- `isNotRequired` tests the STRUCTURE (`steps.length === 0`), not the new
176
- `notRequiredReason` field: a subject schema that never declared that field drops it on
177
- write, so a predicate reading it would answer differently before and after the save. The
178
- reason is provenance for the audit trail only — pinned by a test that strips it and asserts
179
- the predicate is unmoved.
180
-
181
- **The hazard, stated in the docblock:** this makes `isApproved` true with no human
182
- decision. It is correct only where a policy engine ASKED and got "no policy applies" —
183
- never as a fallback for a lookup that failed or was never configured. First consumer:
184
- `@classytic/arc-approval` 0.4.0 `whenNoPolicy: 'not-required'`.
185
-
186
- ## [0.22.0] - 2026-08-12
187
-
188
- ### Added — outbox fencing tokens (`claimPendingFenced`)
189
-
190
- - **`OutboxStore.claimPendingFenced?`** (additive, feature-detected — mirrors repo-core's `LockAdapter.tryAcquireFenced`): same atomic semantics as `claimPending`, returning `OutboxClaimedEvent { event, fencingToken }`. The token is minted by the STORE per claim epoch — a takeover after lease expiry mints STRICTLY GREATER, so a stale ex-holder's `acknowledge`/`fail` (now accepting `fencingToken?`) throws `OutboxOwnershipError`. This closes the overlap that consumer-id ownership checks narrow but cannot close: holder ids recur; epochs do not.
191
- - `MemoryOutboxStore` implements it; `claimPending` delegates to the fenced path (one claim implementation). Stale-ack rejection is mutation-proven.
192
-
193
- ### Added — `./period`: `shiftPeriod(period, n)` — calendar arithmetic without instants
194
-
195
- 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.
196
-
197
- 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").
198
-
199
- **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`.
200
-
201
- ### Docs — `./environment` docblock refinement (no API change)
202
-
203
- 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.
204
-
205
- ## [0.21.0] - 2026-08-10
206
-
207
- ### Added — `./environment`: canonical `NODE_ENV` classification
208
-
209
- `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.
210
-
211
- 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.
212
-
213
- Exports: `classifyEnv`, `EnvClass`, `isProductionEnv`.
214
-
215
- ### Added — `publint` gate on build
216
-
217
- `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.
218
-
219
- ## [0.20.0] - 2026-08-05
220
-
221
- ### Added — `./period` OWNS timezone-aware period resolution (was two copies above the kernels)
222
-
223
- `Period` was `{ year, month?, quarter? }` with no timezone and no resolver, so it
224
- could not express "the business month of August in Asia/Dhaka". The resolver
225
- existed TWICE, both ABOVE the kernels — `@spinekit/kit/period`
226
- (`createBusinessCalendar`) and be-prod's `#lib/utils/business-date` — which meant
227
- a kernel could reach neither and re-derived the boundary inline with `Date.UTC`.
228
- Five silent UTC-month defects came out of that (`aggregateMonthlyVat`, the
229
- sales-fact reconciler, and three in bd-tax Mushak).
230
-
231
- This is a MOVE of the proven `createBusinessCalendar` logic, not a re-derivation:
232
- same civil-date round-trip through `/timezone`, same half-open ranges, same
233
- DST-exactness.
234
-
235
- - **`Period.timezone?`** — the IANA zone a period is DEFINED in, so a persisted or
236
- transported period keeps its own calendar.
237
- - **`resolvePeriod(period, timezone?)`** → half-open `DateRange`. Throws when no
238
- zone is available anywhere (never silently answers in UTC) and throws when the
239
- period's zone and the caller's zone DISAGREE.
240
- - **`resolveDay` / `resolveMonth` / `resolveQuarter` / `resolveYear` /
241
- `resolveDateSpan` / `dayStart`** — the granularity-specific forms. All return
242
- HALF-OPEN `[start, end)`, which tiles exactly.
243
- - **`periodOf(instant, granularity, timezone)`** — the inverse: which filing
244
- period an instant belongs to on the LOCAL calendar.
245
- - **`parsePeriod` / `formatPeriod` / `granularityOf` / `periodTimeZone`** —
246
- `'YYYY'` · `'YYYY-MM'` · `'YYYY-Qn'`. `parsePeriod` REFUSES a `'YYYY-MM-DD'`
247
- rather than reading it as its month (a ~30× widening).
248
- - **`inclusiveEnd(range)`** — `end - 1ms` for legacy `$lte` call sites, derived
249
- from the resolved end so it stays DST-exact.
250
- - **`PeriodError`** + **`PeriodErrorCode`** (`AMBIGUOUS_PERIOD` ·
251
- `INCOMPLETE_RANGE` · `INVALID_FIELD` · `MISSING_TIMEZONE` · `TIMEZONE_CONFLICT`
252
- · `INVALID_LABEL`).
253
-
254
- A `Period` carrying two resolution shapes (`month` + `quarter`, or either with
255
- `start`/`end`) now throws instead of preferring one.
256
-
257
- ### Added — `./idempotency`: the claim + lease CONTRACT
258
-
259
- Three kernels hand-rolled incompatible versions of one state machine — `cart`
260
- (`in_flight`/`succeeded`/`failed`, the only one with crash-window leasing),
261
- `catalog` offers (`pending`/`completed`, no lease), `contract` amendments
262
- (implicit). Types + PURE helpers only; no persistence, no collection, no driver.
263
-
264
- - **`ClaimOutcome = 'claimed' | 'replayed' | 'in_flight'`** — three-valued, and
265
- `in_flight` is NOT a failure. Mapping a live concurrent lease onto an error
266
- licenses a retry, which is a double-apply.
267
- - **`IdempotencyClaim`** (`identity` · `requestFingerprint` · `state` ·
268
- `leaseToken` · `leaseExpiresAt` · `attempts` · `result`), **`ClaimResult`**,
269
- **`ClaimState`**, **`IdempotencyIdentity`**.
270
- - **`decideClaim(existing, request)`** — the whole state machine as one pure
271
- function. Throws `FINGERPRINT_MISMATCH` (same key, different body) and
272
- `MISSING_REPLAY` (terminal record with no stored result) rather than answering.
273
- - **`completeClaim` / `renewLease`** — both refuse when the caller lost its lease,
274
- so a superseded attempt cannot overwrite the winner's result.
275
- - **`identityKey`** (canonical-JSON composite, so a delimiter inside a scope value
276
- cannot collide two identities), **`fingerprintRequest`**, **`newLeaseToken`**,
277
- **`isLeaseExpired`**, **`holdsLease`**, **`isInFlight`**, **`DEFAULT_LEASE_MS`**.
278
-
279
- ### Changed — BREAKING: five API decisions taken before spine wiring starts
280
-
281
- - **`./cadence` — `Cadence.timezone` is now AUTHORITATIVE, not display-only.**
282
- It was documented as decorative while `stepMonthly`/`stepYearly` used
283
- `getUTCMonth()`/`getUTCDate()`, so `timezone: 'Asia/Dhaka'` + `dayOfMonth: 1`
284
- with a midnight anchor placed every occurrence on the local **2nd**. All four
285
- kinds now run on the zone's wall clock when one is set (daily advances LOCAL
286
- days — 23h/25h across a DST transition), and stay UTC when it is absent.
287
- `validateCadence` rejects an unresolvable zone with the new
288
- `INVALID_TIMEZONE`. **Behavioural change for any persisted cadence that
289
- already carries a `timezone`** — the occurrences move to where the field
290
- always claimed they were.
291
- - **`./approval` — `assertApproved` no longer passes on a `null` chain.** It now
292
- throws the new `CHAIN_MISSING`. Absence is not approval, and a mongoose schema
293
- that never declared `approvalChain` strips it on write, so the gate could not
294
- tell "no approval configured" from "approval silently lost". The explicit
295
- opt-out is the new **`assertApprovedIfPresent`**. The second parameter is now
296
- an options object (`{ message }`), not a bare string.
297
- - **`./payment-events` — every payload carries an `eventType` discriminant, and
298
- there is a `payment.unknown` event.** `PaymentInitiatedPayload` and
299
- `PaymentSucceededPayload` were structurally IDENTICAL with no discriminant on
300
- the union, so a handler typed for "funds received" accepted "money in flight".
301
- Adds **`PaymentUnknownPayload`** (the port contract's third outcome had no
302
- event, so an unobserved result had to be forced into SUCCEEDED or FAILED),
303
- **`PaymentEventPayloadMap`**, **`isPaymentEvent`**, **`isFundsReceived`**, and
304
- three compile-time drift guards over the map.
305
- - **`./currency` — `convertWithSnapshot` / `reverseWithSnapshot` take and return
306
- `Money`.** A bare `number` meant nothing checked the amount was denominated in
307
- `fx.sourceCurrency`; a USD→BDT snapshot applied to a EUR amount type-checked
308
- and returned a plausible wrong number. Both now throw `CurrencyMismatchError`
309
- on a mismatch, handle differing minor-unit exponents (USD 2 → JPY 0), and take
310
- an explicit **`FxRounding`** (`'half-away-from-zero'` default · `'floor'` ·
311
- `'ceil'`) instead of returning an unrounded float for each caller to round
312
- differently.
313
- - **`./money` — `Money.currency` is `CurrencyCode`, not `CurrencyCode | string`.**
314
- A union with `string` collapses to `string`, so the brand was decoration on
315
- the one type where it matters and `money()` validated nothing. `money()`,
316
- `fromMajor()` and `sumMoney()` now validate and brand; `isMoney` format-checks
317
- the code instead of `typeof === 'string'`. New **`currencyCode(value)`**
318
- (throwing constructor) and **`InvalidCurrencyCodeError`**.
319
- **`CurrencyMismatchError` is now DECLARED in `./currency`** (so the FX seam can
320
- throw it without an ESM cycle) and re-exported from `./money`, so existing
321
- imports are unaffected.
322
-
323
- Migration: a `Money` object literal now needs a branded code —
324
- `money(1999, 'USD')`, or `{ amount: 1999, currency: currencyCode('USD') }`.
325
-
326
- ### Changed — six silent-permissiveness defects now FAIL instead of answering
327
-
328
- Every item below previously returned a plausible-looking value where it should
329
- have errored. All are behaviour changes for malformed input only; correct input
330
- is unaffected. Falsifying tests live in `tests/unit/fail-loud-regressions.test.ts`.
331
-
332
- - **`./period` — an Invalid Date no longer reports "no overlap".** `rangesOverlap`,
333
- `isWithin` and `rangeDurationMs` throw the new `DateRangeError` on a non-finite
334
- endpoint; `isDateRange` returns `false` for one. Every comparison against `NaN`
335
- is `false`, so a corrupt range used to answer "no conflict" — the PERMISSIVE
336
- answer for the three documented consumers (order booking collision, flow
337
- reservation-window collision, promo campaign overlap), i.e. a silent double-book.
338
- - **`./currency` — the ISO 4217 exponent table is now exhaustive.** All 0-decimal
339
- (`BIF CLP DJF GNF ISK KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF`), 3-decimal
340
- (`BHD IQD JOD KWD LYD OMR TND`) and 4-decimal (`CLF UYW`) currencies are listed,
341
- so `minorUnitFactor`'s `?? 100` fallback is reachable only by a code that is not
342
- a real ISO currency. Previously `fromMajor(1500, 'GNF')` stored 150 000 minor
343
- units — a 100× error that looks like a number. New `isKnownCurrency(code)`
344
- distinguishes "listed" from "assumed" for boot-time config validation.
345
- - **`./proration` — a missing allocated part throws instead of becoming `0`.** The
346
- `?? 0` fallbacks in `splitByPeriodFraction` / `allocateMoneyByFraction` are now
347
- `ProrationError('ALLOCATION_INVARIANT')`. A zero credit and a genuinely
348
- fully-consumed period were indistinguishable downstream.
349
- - **`./state-machine` — `assertAndClaim` rejects an EMPTY `from` list.**
350
- `validSources(to)` returns `[]` for an unreachable status, and the documented
351
- `from: machine.validSources(x)` idiom then ran the assert loop zero times and
352
- handed the CAS an empty allow-list. An empty allow-list must never mean "any".
353
- - **`./timezone` — the offset regex is anchored.** An offset label the runtime
354
- emits in an unexpected shape (e.g. an unpadded `GMT+5:30`) matched the optional
355
- bare-`GMT` branch and resolved to **UTC**, silently shifting every business-day
356
- boundary derived from it. Unrecognised labels now throw; `\d{1,2}` accepts the
357
- unpadded variant rather than mis-reading it.
358
- - **`./canonical` — class instances are rejected, not collapsed.** A
359
- `Types.ObjectId`, `Buffer` or hydrated Mongoose subdocument has no own
360
- enumerable keys, so it digested as `{}` and two manifests referencing DIFFERENT
361
- documents hashed identically — the integrity check reported "unchanged".
362
- `canonicalJson` now throws `CanonicalizeError` for any object whose prototype is
363
- neither `Object.prototype` nor `null`. Plain objects, null-prototype objects,
364
- arrays and `Date` are unaffected.
365
-
366
- ### Changed — the publish smoke gate now sweeps the whole export map
367
-
368
- `tests/smoke/smoke.mjs` derives its subpath list from `package.json#exports`
369
- (44/44) instead of a hand-maintained list that covered 23, and asserts each entry
370
- declares both `types` and `default` and that both files exist. The gate runs in
371
- `prepublishOnly`; previously a subpath that lost its tsdown entry would have
372
- published an export map pointing at a missing file with the gate printing OK.
373
-
374
- ## [0.19.0] - 2026-08-01
375
-
376
- ### Added — `./monetization`: canonical pricing classification contract
377
-
378
- - **`@classytic/primitives/monetization`** — the single home for how a product
379
- is sold and priced, so the classification survives end-to-end across catalog,
380
- revenue, order, and entitlement kernels without each maintaining its own enum.
381
- - **`Monetization`** discriminated union — `FreeMonetization | OneTimeMonetization |
382
- SubscriptionMonetization | BundleMonetization | UsageMonetization`, each
383
- carrying its own nested pricing shape.
384
- - **`MONETIZATION_KINDS`** + **`MonetizationKind`** + **`isMonetizationKind`** —
385
- exhaustive kind list with runtime guard.
386
- - Type-guard predicates: **`isFreeMonetization`**, **`isOneTimeMonetization`**,
387
- **`isSubscriptionMonetization`**, **`isBundleMonetization`**,
388
- **`isUsageMonetization`**.
389
- - Supporting types: `PriceTier`, `OneTimePricing`, `SubscriptionPlan`,
390
- `UsageTier`, `UsageRating`, `DurationUnit`, `MoneyByCurrency`.
391
-
392
- ### Added — `./proration`: pure proration arithmetic
393
-
394
- - **`@classytic/primitives/proration`** — timezone-agnostic, policy-free
395
- proration math. Holds no commercial policy (no upgrade/downgrade semantics,
396
- no discount or tax logic) — those live in the contract kernel.
397
- - **`periodProgress(input)`** — fraction of a billing period consumed given
398
- start, end, and current instants as `Date`. Returns `PeriodFraction`.
399
- - **`splitByPeriodFraction(amount, fraction, granularity?)`** — split a `Money`
400
- amount into consumed/remaining parts; `granularity: 'whole_day'` (default) or
401
- `'exact'`.
402
- - **`allocateMoneyByFraction(amount, fraction)`** — proportional allocation
403
- using the largest-remainder allocator so `consumed + remaining === amount`
404
- exactly (no penny drift).
405
- - **`ProrationError`** + **`ProrationErrorCode`** (`INVALID_PERIOD` |
406
- `INVALID_FRACTION`) — typed errors for out-of-range inputs.
407
- - Supporting types: `PeriodFraction`, `PeriodProgressInput`,
408
- `ProrationGranularity`.
409
-
410
- ## [0.18.0] - 2026-07-29
411
-
412
- ### Added — `./subject`: `SubjectRef` polymorphic identity pointer
413
-
414
- - **`@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.
415
- - **`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.
416
- - **`SubjectRef`** type exported from `@classytic/primitives/subject`.
417
-
418
- ### Added — `./state-diagram`: Mermaid diagram from a `StateMachine`
419
-
420
- - **`@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.
421
- - **`StateDiagramOptions`** — `title?`, `highlight?` (mark specific states), `noteOn?` (per-state annotations).
422
-
423
- ### Added — `./suspension`: time-bounded suspension/pause policy
424
-
425
- - **`@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).
426
- - **`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.
427
- - **`SuspensionPolicy`** — `maxDaysPerPeriod`, `minDurationDays`, `period` ('annual' | 'rolling').
428
- - **`SuspensionEntry`** — a historical pause record (`startedAt`, `endedAt?`, `reason?`).
429
- - **`SuspensionResult`** — `autoResumeAt`, `daysUsed`, `daysRemaining`, `status`.
430
-
431
- ### Added — `OutboxStore.transactionalSave` (`/outbox`)
432
-
433
- - **`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.
434
- - **`MemoryOutboxStore.transactionalSave = false`** — explicitly declared; an in-memory store cannot participate in a Mongo transaction, so its write survives a rollback.
435
-
436
- ### Changed — `shelf-life.ts` deduplicates `addDays`
437
-
438
- - `addDays` is now imported from `./calendar` instead of being re-defined inline. No behavior change.
439
-
440
- ## [0.16.0] - 2026-07-27
441
-
442
- ### Added — `assertApproved` + `CHAIN_INCOMPLETE` (`/approval`)
443
-
444
- - **`ApprovalErrorCode` gains `'CHAIN_INCOMPLETE'`** — the single canonical
445
- error code for "finalize/commit attempted before the chain reached `approved`".
446
- Hosts route it through `rethrowApprovalError` to the `approval.chain_incomplete`
447
- wire code so all commit/post/finalize boundaries raise the same structured error.
448
- - **`assertApproved(chain, message?)`** — canonical finalize-time gate: asserts
449
- the chain has reached `approved`, else throws `ApprovalError('CHAIN_INCOMPLETE')`.
450
- A `null`/absent chain passes (no chain required). Use at every journal-entry
451
- post, PO approve, transfer dispatch, etc. to replace per-domain hand-rolled
452
- divergent codes with one definition.
453
-
454
- Purely additive — no existing exports changed.
455
-
456
- ## [0.15.0] - 2026-07-25
457
-
458
- ### Added — `/retention`: `PurgeEvidence` value object
459
-
460
- - **New `@classytic/primitives/retention` subpath** exporting **`PurgeEvidence`**
461
- — the persisted evidence record for a GDPR / data-retention purge operation
462
- (proof of what was purged, over what scope, by whom, why, and whether
463
- analytical measures were retained). Shape: `{ id, subject: { ref, model? },
464
- scope, strategy: 'hard'|'soft'|'anonymize', measuresRetained, processed,
465
- occurredAt, actor: { ref, kind: 'user'|'system'|'service' }, reason,
466
- legalBasis?, status: 'completed'|'partial'|'failed', operationId?,
467
- startedAt?, completedAt?, results?: PurgeResourceResult[],
468
- verification?: PurgeVerificationSummary }`.
469
- - **`createPurgeEvidence(input)`** — pure builder (auto-fills `id` + `occurredAt`
470
- when absent, defaults `status` to `'completed'`). **`isPurgeEvidence(value)`**
471
- type guard + **`assertPurgeEvidence(value)`** throwing assertion.
472
-
473
- ### Added — `/canonical`: strict deterministic JSON + integrity digest
474
-
475
- - **New `@classytic/primitives/canonical` subpath** — `canonicalJson(value)`
476
- produces a deterministic string (keys sorted recursively, `Date` → `{"$date":"<iso>"}`,
477
- strict rejection of `undefined`/`NaN`/`BigInt`/`Map`/`Set`/cycles with
478
- `CanonicalizeError`). **`sha256Hex(input)`** and **`canonicalDigest(value)`**
479
- (= `sha256Hex(canonicalJson(value))`) — integrity checksums, not tamper-proof
480
- seals. Uses `node:crypto` directly (same precedent as `/otp`).
481
-
482
- ### Added — `/event-infra`: `propagateHandlerErrors` option
483
-
484
- - **`InProcessEventBusOptions.propagateHandlerErrors?: boolean`** (default
485
- `false` — existing error-isolating behavior unchanged). When `true`, `publish`
486
- runs handlers sequentially and rethrows the first handler error to the
487
- publisher — for single-consumer projection/relay lanes where a failed handler
488
- must not be silently acked. `publishMany` maps each event's failure into its
489
- `PublishManyResult` entry instead of rejecting the batch.
490
-
491
- ## [0.13.0] - 2026-07-22
492
-
493
- ### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
494
-
495
- - **`@classytic/primitives/unit-cost-rate`** — fractional per-unit monetary
496
- rates stored as scaled integers (`scaledAmount = minorPerUnit × RATE_SCALE`,
497
- `RATE_SCALE = 1_000_000`). Solves the WAC / FIFO valuation problem where
498
- dividing a total by a fractional quantity yields a sub-minor-unit rate
499
- (100 paisa ÷ 3 units = 33.333… paisa/unit) that must not lose precision.
500
- Exports:
501
- - `unitCostRate(minorPerUnit, currency)` — direct rate constructor
502
- - `unitCostRateFromTotal(totalMinor, quantity, currency)` — WAC/FIFO
503
- constructor; bigint-safe, QTY_SCALE grid for fractional quantities
504
- - `extendedAmount(rate, qty, mode?)` — the ONE rate→total rounding boundary;
505
- bigint multiplication to keep large amounts exact; default `'half-even'`
506
- (banker's rounding), `'half-up'` available for legacy parity
507
- - `rateMinorPerUnit(rate)` — display helper (fractional number, NOT for math)
508
- - `isUnitCostRate(value)` — structural type guard
509
- - `UnitCostRate<C>` interface, `UnitCostRateError`, `UnitCostRateErrorCode`,
510
- `RoundingMode`, `RATE_SCALE`
511
- - For distributing a top-down charge across lines with no lost minor unit,
512
- compose with `allocate` from `@classytic/primitives/split-allocation`.
513
-
514
- ### Changed — `/outbox` is now the canonical contract owner
515
-
516
- - **`@classytic/primitives/outbox` owns the transactional-outbox contract.**
517
- Previously this file was documented as a mirror of `@classytic/arc`'s copy
518
- ("arc is source of truth, keep bit-identical") while arc's docs said the
519
- opposite — a manual-sync drift risk. Settled: primitives owns pure
520
- cross-package contracts (events + outbox); arc owns runtime behavior
521
- (`EventOutbox` relay, stores, backoff, Fastify integration) and, from
522
- arc 2.24+, imports + re-exports this contract instead of duplicating it.
523
- - **Cross-package `instanceof` now works.** Because arc re-exports these exact
524
- `OutboxOwnershipError` / `InvalidOutboxEventError` classes, a store built
525
- against primitives throws the same class identity arc's relay catches.
526
- Under the old duplication, arc's `instanceof` checks silently missed
527
- primitives-thrown ownership errors.
528
- - No export names or signatures changed — contract surface is identical.
529
-
530
- ## [0.11.0] - 2026-07-08
531
-
532
- ### Added — eKYC vocabulary + secure OTP primitive
533
-
534
- - **`@classytic/primitives/identity-verification`** — cross-package eKYC data shapes:
535
- `IdentityDocumentType`, `VerificationCheck`, `VerificationLevel`, `SubjectKind`,
536
- `IdentitySubject`, `DocumentReference`, `IdentityProviderCapabilities`,
537
- `VerificationStatus`, `CheckResult`, `CreateVerificationParams`,
538
- `VerificationSession`, `VerificationWebhookEvent`. Pure helpers: `isVerified`,
539
- `checksSatisfy`, `deriveStatus`. The identity twin of `payment-gateway` — shapes
540
- here, contracts in engines, provider implementations anywhere. Serves `@classytic/esign`
541
- (IdentityBridge port), `@classytic/payee` (ComplianceBridge), and PSP merchant KYC.
542
- - **`@classytic/primitives/otp`** — secure OTP challenge lifecycle. Guarantees: code
543
- never persisted (only HMAC-SHA256 digest stored), constant-time `timingSafeEqual`
544
- comparison, rejection-free `randomInt` CSPRNG (no modulo bias), bounded attempt
545
- counter, TTL expiry, single-use verified state. Exports: `createOtpChallenge`,
546
- `verifyOtpChallenge`, `isOtpVerified`, `isOtpActive`. Delivery stays in
547
- `@classytic/notifications` — this primitive owns only the verify half.
548
-
549
- ## [0.9.1] - 2026-07-04
550
-
551
- ### Added
552
-
553
- - **`CURRENCY_PATTERN` exported from `/currency`** — the ISO 4217 regex
554
- (`/^[A-Z]{3}$/`) behind `isCurrencyCode` / `toCurrencyCode`. Consumers
555
- embed the SAME pattern in JSON-Schema-representable validators (zod
556
- `.regex`, Mongoose `match`, OpenAPI `pattern`) instead of hand-rolling
557
- copies — `.refine(isCurrencyCode)` validates at runtime but is dropped
558
- by `z.toJSONSchema`, silently losing the constraint from generated API
559
- docs.
560
-
561
- ## [0.9.0] - 2026-07-02
562
-
563
- ### Added — `/timezone`: IANA resolution + civil dates (the door for `/calendar`'s escape hatch)
564
-
565
- `/calendar` owns wall-clock ARITHMETIC over a fixed `UtcOffsetMinutes` and
566
- tells hosts to "resolve the offset for the instant and pass it in" — but the
567
- stack had no shared resolver, so consumers hand-rolled `Intl.DateTimeFormat`
568
- (three divergent copies found: catalog ×2, be-prod POS). `/timezone` is that
569
- resolver — zero-dep (Intl IS the ICU tz database), Temporal-shaped names for
570
- a future mechanical migration:
571
-
572
- - **`zoneOffsetMinutes(instant, zone)`** — per-instant, DST-exact offset;
573
- composes directly with `/calendar`: `startOfMonth(i, zoneOffsetMinutes(i, z))`.
574
- - **`localTimeParts(instant, zone)`** — `{ isoWeekday, hour, minute,
575
- minutesOfDay }` in the zone; what pricing windows / slot grids /
576
- working-hours checks evaluate against (canonicalizes catalog's resolvers).
577
- - **`CivilDate`** (branded `'YYYY-MM-DD'`) + `civilDate` / `isCivilDate` /
578
- `civilDateOf(instant, zone)` / `civilDateToInstant(cd, zone, time?)`
579
- (two-pass DST-safe) / `addCivilDays` / `civilDaysBetween` — the type for
580
- hotel nights, POS business dates, VAT filing years. Lexicographic ==
581
- chronological, so it's a range-queryable Mongo string key.
582
- - **`isValidTimeZone(zone)`** — boot-time host-config validation;
583
- **`listTimeZones()`** + **`zoneOffsetLabel(instant, zone)`** — the data
584
- source + label for Google-Calendar-style zone pickers, straight from ICU
585
- (`Intl.supportedValuesOf`), no bundled list to go stale.
586
- - `TimeZoneError`; formatter instances cached per zone.
587
-
588
- No arithmetic here (that stays in `/calendar`) — resolution only.
589
-
590
- ## [0.7.2] - 2026-05-30
591
-
592
- ### Added — `BankTransaction` per-account routing + pending lifecycle fields
593
-
594
- Three additive optional fields on `BankTransaction` (`/bank-transaction`):
595
-
596
- - **`sourceAccountId?: string`** — the vendor-side account this row belongs to
597
- (Plaid `account_id`, Xero `BankAccount.AccountID`). Sync feeds return
598
- transactions spanning multiple accounts in one batch; this lets consumers
599
- route each row to the right account per-transaction (multi-account sync)
600
- instead of forcing the whole batch onto one account. Statement formats
601
- (single-account files) leave it undefined — the account lives on
602
- `BankStatement.account`.
603
- - **`pending?: boolean`** — the entry hasn't posted/cleared yet (real-time
604
- feeds like Plaid `pending: true`; statement formats leave it undefined).
605
- Consumers must treat pending rows as provisional — don't reconcile/post
606
- them as final.
607
- - **`supersedesExternalId?: string`** — set on a POSTED row that replaces an
608
- earlier PENDING one, carrying the pending row's `externalId` (Plaid
609
- `pending_transaction_id`). Pending and posted rows have different
610
- `externalId`s, so consumers MUST use this back-pointer to drop/replace the
611
- superseded pending row on ingest — otherwise both persist as duplicates.
612
-
613
- Backward-compatible (both optional). Closes a fidelity gap where Plaid's
614
- pending state was dropped at the canonical boundary, so downstream consumers
615
- (`@classytic/fin-io` Plaid mapper, `@classytic/revenue` import) couldn't
616
- distinguish provisional rows or dedupe pending→posted.
617
-
618
- ## [0.7.1] - 2026-05-26
619
-
620
- ### Added — workflow + scheduling + composition primitives
621
-
622
- Four new flat subpaths (additive):
623
-
624
- - **`/status-history`** — append-only status transition log primitive.
625
- - **`/condition`** — declarative condition expressions for workflow gates.
626
- - **`/mixin`** — composition helpers for primitive object assembly.
627
- - **`/sla-policy`** — SLA policy definitions paired with the existing `/sla` runtime.
628
-
629
- ### Added — payment event coverage
630
-
631
- Expanded `PAYMENT_EVENT_TYPE` catalogue to cover auth/capture and dispute
632
- lifecycles: `AUTHORIZED`, `CAPTURED`, `AUTH_VOIDED`, `DISPUTED`,
633
- `DISPUTE_WON`, `DISPUTE_LOST`, `SETTLED`.
634
-
635
- `PaymentRefundedPayload` and `PaymentReversedPayload` now carry
636
- `originalAmount` (and `reversedAmount` / `isPartial` on reversed) so
637
- consumers can detect partial vs full operations without a follow-up
638
- Payment lookup.
639
-
640
- ## [0.7.0] - 2026-05-26
641
-
642
- ### Added — payment domain primitives
643
-
644
- Three new subpaths shared across every payment-aware package
645
- (`@classytic/invoice`, `@classytic/revenue`, `@classytic/order`, future
646
- POS / AP / subscription packages). Each ships under a flat subpath so
647
- consumers tree-shake what they don't import.
648
-
649
- - **`/payment-method-kind`** — `PaymentMethodKind` universal vocabulary
650
- (kind vs host-registered `methodCode`) so packages can speak `kind`
651
- while hosts speak `code`.
652
- - **`/payment-allocation-status`** — orthogonal allocation +
653
- reconciliation status enums for payment legs (`unallocated`,
654
- `partial`, `allocated`, `over_allocated`, plus reconciliation states).
655
- - **`/payment-events`** — pure TS payload contracts for payment domain
656
- events. Wire-only (no Zod, no runtime). Anchored on `Money` +
657
- `PaymentMethodKind`; causation/correlation stay on `EventMeta`.
658
-
659
- No breaking changes.
660
-
661
- ## [0.6.0] - 2026-05-17
662
-
663
- ### Added — five new primitives for CRM / workflow domains
664
-
665
- Five new primitives covering gaps the existing surface didn't address.
666
- Each ships under its own flat subpath import (no breaking changes).
667
-
668
- - **`/phone`** — `PhoneNumber` value object + `parsePhone(input)` that
669
- normalizes free-form input to E.164, captures the country calling code
670
- as a discrete field, and reports failures via `Result<PhoneNumber,
671
- PhoneError>`. Zero external deps; ITU-T E.164 prefixes inlined. Hosts
672
- that want region-of-issue / line-type detection layer on top with
673
- libphonenumber-js.
674
-
675
- - **`/status-history`** — Generic, immutable status-change log:
676
- `StatusChangeEntry<TStatus>`, `appendStatus()`, `timeInStatus()`,
677
- `lastTransitionTo()`. Captures `durationInPriorMs` at write time so
678
- funnel-velocity dashboards ("avg time in Qualification") work without
679
- re-walking timestamps. Pairs naturally with `/state-machine`.
680
-
681
- - **`/condition`** — Tiny JSON-serializable predicate DSL: `FieldCondition`
682
- (`{ field, op, value }`) composed via `AllCondition` / `AnyCondition`
683
- / `NotCondition`, plus a total `evaluate(condition, target)` and a
684
- `validateCondition()` for boot-time schema checks. Powers SLA scope
685
- filters, drip "if opened/clicked" branches, automation triggers, and
686
- permission row-scopes — anywhere a predicate needs to round-trip
687
- through a database.
688
-
689
- - **`/mixin`** — Additive type composition without subclassing:
690
- `withMixin<T, K, M>()` / `getMixin()` / `hasMixin()` / `withoutMixin()`.
691
- Lets a `Contact` carry per-domain payloads (`mixins.customer`,
692
- `mixins.lead-fit`) on a typed `mixins` namespace without bloating the
693
- base type. Plain object composition — no decorators, no global
694
- hierarchy, no proxies.
695
-
696
- - **`/sla-policy`** — Higher-order SLA layered on the existing `/sla`:
697
- priority matrix (`urgent` / `high` / `normal` → durations), first-
698
- response vs rolling-response semantics, and working-hours window
699
- (weekdays + start/end minute + holidays). `defineSLAPolicy()` validates
700
- at boot; `evaluateSLAStatus()` produces `{ kind, responseBy,
701
- remainingMs, breached }` from a policy + per-entity inputs. The
702
- existing simple `/sla` interface is unchanged — `sla-policy` derives
703
- concrete `SLA` instances from a policy + priority and delegates to it.
704
-
705
- ### Changed — source-tree reorganization (no public-surface impact)
706
-
707
- The 24 primitive source files moved from a flat `src/*.ts` layout into
708
- six category folders so the package is self-documenting at the file
709
- system level:
710
-
711
- ```
712
- src/
713
- money/ money, currency, split-allocation, bank-transaction, payment-gateway
714
- identity/ person, address, phone
715
- scheduling/ period, cadence, sla, sla-policy
716
- workflow/ state-machine, status-history, approval, hold, condition
717
- events/ events, outbox
718
- composition/ mixin, reference, context, brand, result
719
- ```
720
-
721
- **Public subpath imports are unchanged** — `@classytic/primitives/money`,
722
- `@classytic/primitives/sla`, etc. still resolve. tsdown emits each entry
723
- to `dist/<name>.mjs` regardless of source nesting. Existing consumers
724
- upgrade with `npm install @classytic/primitives@^0.6.0` and a no-op
725
- diff.
726
-
727
- ### Dependencies
728
-
729
- Still zero runtime deps. Node 22+.
730
-
731
- ## [0.5.0] - 2026-05-05
732
-
733
- ### Added — `/bank-transaction` subpath
734
-
735
- Canonical bank-feed shapes shared across `@classytic/fin-io` (parsers
736
- produce these), `@classytic/revenue` (`transactionRepository.import()`
737
- consumes them), and `@classytic/ledger` (`bankStatementMapper`
738
- consumes them). Single source of truth for the cross-package
739
- vocabulary; eliminates the structural mirrors fin-io and revenue
740
- were carrying.
741
-
742
- Exports: `BankTransaction`, `BankCounterparty`, `BankAccount`,
743
- `BankStatement`, `BankStatementSource`, `BankImportReport`,
744
- `BankImportRowError`. All anchored on `Money` from `/money` (integer
745
- minor units, `number`).
746
-
747
- ### Added — `/payment-gateway` subpath
748
-
749
- Canonical payment-gateway data shapes. **Lets any provider package
750
- (Stripe, Razorpay, SSLCommerz, PayPal, bKash, Nagad, manual, …)
751
- implement against primitives' types without depending on revenue's
752
- heavyweight runtime.** Provider authors peer-dep on
753
- `@classytic/primitives` only.
754
-
755
- Exports: `CreateIntentParams`, `PaymentIntent`, `PaymentResult`,
756
- `RefundResult`, `WebhookEvent`, `ProviderCapabilities`. All `amount`
757
- fields are `Money` from `/money` (no flat `amount: number, currency:
758
- string` pairs — currency travels inside Money).
759
-
760
- The `PaymentProvider` abstract class — the *contract* every provider
761
- must satisfy — stays in `@classytic/revenue`. Revenue is the data
762
- engine that consumes providers; provider packages live elsewhere.
763
-
764
- ### Architectural intent
765
-
766
- Revenue 3.0 + primitives 0.5 + fin-io 0.3 establish a clean
767
- three-tier seam:
768
-
769
- 1. **primitives** — pure data shapes (no runtime, no deps). Owns
770
- `Money`, `BankTransaction`, `PaymentIntent`, every cross-
771
- package vocabulary type.
772
- 2. **revenue** — the engine. Owns the abstract contracts
773
- (`PaymentProvider`, `BankFeedProvider`), state machines, repos.
774
- Consumes primitives shapes.
775
- 3. **provider packages** (`revenue-stripe`, `revenue-razorpay`,
776
- `@classytic/fin-io/adapters/revenue`, …) — implementations.
777
- Peer-dep on primitives. Optional peer-dep on revenue for the
778
- abstract base class.
779
-
780
- This eliminates the cycle where every provider package transitively
781
- pulled mongoose + mongokit just to know what a `PaymentIntent`
782
- looked like. Tree-shaking, build times, and dep-graph honesty all
783
- improve.
784
-
785
- ### Migration
786
-
787
- Consumers MUST import from primitives subpaths. No re-exports from
788
- revenue or fin-io (PACKAGE_RULES P2):
789
-
790
- ```ts
791
- // Before
792
- import type { PaymentIntent, CreateIntentParams } from '@classytic/revenue';
793
- import type { CanonicalTransaction, Money } from '@classytic/fin-io';
794
-
795
- // After
796
- import type {
797
- PaymentIntent,
798
- CreateIntentParams,
799
- } from '@classytic/primitives/payment-gateway';
800
- import type { BankTransaction } from '@classytic/primitives/bank-transaction';
801
- import type { Money } from '@classytic/primitives/money';
802
- ```
803
-
804
- Provider helper classes (`PaymentIntent`, `PaymentResult`,
805
- `RefundResult`, `WebhookEvent`) are deleted from revenue — they were
806
- trivial wrappers around plain objects. Provider implementations now
807
- return plain object literals matching the interface.
808
-
809
- `CreateIntentParams.amount` is now `Money` (`{ amount: number,
810
- currency: string }`), not the flat `amount + currency` pair. Revenue's
811
- repository wraps internally:
812
-
813
- ```ts
814
- const intent = await provider.createIntent({
815
- amount: { amount: params.amount, currency },
816
- metadata: params.metadata,
817
- ...params.paymentData,
818
- });
819
- ```
820
-
821
- ## [0.3.1] - 2026-05-02
822
-
823
- ### Added — state-machine ↔ mongokit/sqlitekit integration
824
-
825
- State-machine primitives now compose directly with the kit-level CAS surface shipped in mongokit 3.13 and the sqlitekit follow-up. Three additions:
826
-
827
- - **`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 })`.
828
- - **`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.
829
- - **`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.
830
-
831
- ```ts
832
- import { defineStateMachine, assertAndClaim } from '@classytic/primitives/state-machine';
833
-
834
- const ORDER_MACHINE = defineStateMachine<OrderStatus>({
835
- name: 'Order',
836
- transitions: {
837
- draft: ['approved', 'cancelled'],
838
- approved: ['shipped', 'cancelled'],
839
- shipped: [],
840
- cancelled: [],
841
- },
842
- });
843
-
844
- // Single-source:
845
- const updated = await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
846
- from: 'draft',
847
- to: 'approved',
848
- patch: { approvedAt: new Date() },
849
- options: { organizationId: ctx.organizationId, session },
850
- });
851
-
852
- // Multi-source via reverse adjacency — "cancel from any legal predecessor":
853
- await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
854
- from: ORDER_MACHINE.validSources('cancelled'), // ['draft', 'approved']
855
- to: 'cancelled',
856
- patch: { cancelledAt: new Date() },
857
- options: { organizationId: ctx.organizationId },
858
- });
859
- ```
860
-
861
- #### `ClaimableRepo<TDoc>` — structural dependency on the kit
862
-
863
- `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).
864
-
865
- ```ts
866
- export interface ClaimableRepo<TDoc> {
867
- claim(
868
- id: string,
869
- transition: {
870
- field?: string;
871
- from: unknown | readonly unknown[];
872
- to: unknown;
873
- where?: Record<string, unknown>;
874
- },
875
- patch?: Record<string, unknown>,
876
- options?: Record<string, unknown>,
877
- ): Promise<TDoc | null>;
878
- }
879
- ```
880
-
881
- #### Why a 0.3.1 (not 0.4.0)
882
-
883
- 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.
884
-
885
- ## [0.3.0] - 2026-04-29
886
-
887
- ### Removed — `/errors` subpath (BREAKING)
888
-
889
- - Deleted `@classytic/primitives/errors` (`ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`).
890
- - 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.
891
-
892
- #### Migration
893
-
894
- ```diff
895
- - import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/primitives/errors';
896
- - import { ERROR_CODES } from '@classytic/primitives/errors';
897
- + import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/repo-core/errors';
898
- + import { ERROR_CODES } from '@classytic/repo-core/errors';
899
- ```
900
-
901
- Field shapes and code values are unchanged — pure relocation.
902
-
903
- ### Removed — `/tenant` subpath (BREAKING)
904
-
905
- - Deleted `@classytic/primitives/tenant` (`TenantConfig`, `TenantStrategy`, `TenantFieldType`, `DEFAULT_TENANT_CONFIG`, `resolveTenantConfig`, `ResolvedTenantConfig`).
906
- - 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.
907
- - Mongokit and sqlitekit already peer-dep `@classytic/repo-core`, so consuming `TenantConfig` from there costs zero extra peer deps for those kits.
908
-
909
- #### Migration
910
-
911
- ```diff
912
- - import type { TenantConfig } from '@classytic/primitives/tenant';
913
- - import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/primitives/tenant';
914
- + import type { TenantConfig } from '@classytic/repo-core/tenant';
915
- + import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/repo-core/tenant';
916
- ```
917
-
918
- Field shapes, runtime semantics, and defaults are unchanged. The custom-strategy escape hatch (`strategy: 'custom'` + `resolve: (ctx) => filterShape`) works identically.
919
-
920
- ### Removed — `/pagination` subpath (BREAKING)
921
-
922
- - Deleted `@classytic/primitives/pagination` (`PageParams`, `KeysetParams`, `SortSpec`, `SortDirection`, `OffsetPage<T>`, `KeysetPage<T>`, `AggregatePage<T>`, `emptyOffsetPage`, `emptyKeysetPage`).
923
- - 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.
924
-
925
- ### Migration
926
-
927
- ```diff
928
- - import type { OffsetPage, KeysetPage, AggregatePage, SortSpec } from '@classytic/primitives/pagination';
929
- + import type {
930
- + OffsetPaginationResult,
931
- + KeysetPaginationResult,
932
- + AggregatePaginationResult,
933
- + SortSpec,
934
- + } from '@classytic/repo-core/pagination';
935
- ```
936
-
937
- 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.
938
-
939
- 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.
940
-
941
- ### Why now (and why this is the only breaking change in this release)
942
-
943
- 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.
944
-
945
- ## [0.2.0]
946
-
947
- ### Added
948
-
949
- - **`/state-machine` subpath** — `defineStateMachine<TStatus>({ name, transitions, errorFactory? })`
950
- declarative state-transition primitive. Replaces hand-rolled
951
- `if (status === 'X') throw` blocks across kernels:
952
-
953
- ```typescript
954
- import { defineStateMachine } from '@classytic/primitives/state-machine';
955
-
956
- const ORDER_MACHINE = defineStateMachine<OrderStatus>({
957
- name: 'Order',
958
- transitions: {
959
- draft: ['approved', 'cancelled'],
960
- approved: ['shipped', 'cancelled'],
961
- shipped: [],
962
- cancelled: [],
963
- },
964
- });
965
-
966
- ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
967
- ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
968
- ORDER_MACHINE.canTransition('draft', 'shipped'); // false
969
- ORDER_MACHINE.isTerminal('shipped'); // true
970
- ```
971
-
972
- Generic over the status string-union — `Record<TStatus, readonly TStatus[]>`
973
- on `transitions` enforces exhaustiveness at the call site. Kernels with their
974
- own typed transition error class (e.g. flow's `InvalidTransitionError`) wire
975
- it via `errorFactory` so the thrown type stays domain-consistent. Standalone
976
- callers get the bundled `IllegalTransitionError` (carries `entityType` /
977
- `entityId` / `from` / `to` + `code: 'illegal_transition'` + `status: 422`).
978
-
979
- First consumer: `@classytic/flow` `PROCUREMENT_MACHINE` (procurement-order
980
- approve / cancel transitions).
981
-
982
- ## [0.1.1]
983
-
984
- ### Added
985
-
986
- - **`MinorUnits<C>` branded type on `/money`** — wire-format monetary amount
987
- (integer minor units, branded with phantom currency) for HTTP/JSON fields
988
- where a full `Money` struct is overkill. Erases at runtime; `MinorUnits<'BDT'>`
989
- vs `MinorUnits<'USD'>` collide at compile time. Pair with structured `Money`
990
- for in-process arithmetic. Constructor: `MinorUnits(amount, currency?)`.
991
-
992
- ### Changed
993
-
994
- - **`/tenant` `contextKey` cascade default** — `resolveTenantConfig({ tenantField: 'branchId' })`
995
- now defaults `contextKey` to `'branchId'` instead of `'organizationId'`.
996
- When a host renames `tenantField`, their `OperationContext` carries the id
997
- under the same key in the overwhelming majority of cases; mirroring the
998
- rename is the least-surprise default. Explicit `contextKey` still overrides
999
- (set when the doc field and context key genuinely diverge). Existing call
1000
- sites that supplied both `tenantField` and `contextKey` are unaffected.
1001
-
1002
- ## [0.1.0]
1003
-
1004
- Initial release. Framework-agnostic TypeScript primitives shared across
1005
- Classytic packages. Zero runtime dependencies. Node 22+. ESM only.
1006
- 260 tests (unit + integration) across 20 files.
1007
-
1008
- All subpaths are importable independently; there is **no root barrel
1009
- re-export** — consumers must use subpath imports (`@classytic/primitives/money`,
1010
- `/events`, `/tenant`, …) to keep the Node ESM loader + TypeScript compiler
1011
- from walking the full module graph on any convenience import.
1012
-
1013
- ### Subpaths
1014
-
1015
- - **`/money`** — `Money` type + integer-minor-unit arithmetic (`money`,
1016
- `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `negateMoney`,
1017
- `absMoney`, `compareMoney`, `equalsMoney`, `isZeroMoney`, `isPositiveMoney`,
1018
- `isNegativeMoney`, `isMoney`, `fromMajor`, `toMajor`); `CurrencyMismatchError`.
1019
- - **`/currency`** — `CurrencyCode` brand; `CURRENCIES` table; `MINOR_UNIT_FACTOR`,
1020
- `minorUnitFactor`, `isCurrencyCode`, `toCurrencyCode`.
1021
- - **`/address`** — `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`;
1022
- `toGeoJsonPoint`, `fromGeoJsonPoint`.
1023
- - **`/period`** — `DateRange`, `Period`; `isDateRange`, `isWithin`,
1024
- `rangeDurationMs`.
1025
- - **`/pagination`** — `PageParams`, `OffsetPage<T>`, `KeysetPage<T>`,
1026
- `AggregatePage`, `KeysetParams`, `SortSpec`, `SortDirection`;
1027
- `emptyOffsetPage`, `emptyKeysetPage`.
1028
- - **`/reference`** — `ExternalRef`, `DocumentRef`, `ObjectIdLike`, `IdLike`;
1029
- `idToString`, `isExternalRef`.
1030
- - **`/context`** — `OperationContext` (the identity + tracing bag every
1031
- Classytic package accepts); `ActorRef`.
1032
- - **`/events`** — `EventMeta` (arc v2.9-aligned — 13 fields including
1033
- `schemaVersion`, `causationId`, `partitionKey`, `source`, `idempotencyKey`,
1034
- `aggregate`), `DomainEvent<T>`, `EventTransport`, `EventHandler`,
1035
- `EventLogger`, `DeadLetteredEvent<T>`, `PublishManyResult`; `createEvent`,
1036
- `createChildEvent` (auto-chains `causationId`, inherits `correlationId` /
1037
- `userId` / `organizationId` / `source` / `idempotencyKey` — NOT
1038
- `aggregate`), `matchEventPattern`.
1039
- - **`/outbox`** — transactional outbox **contract** mirrored from arc v2.9:
1040
- `OutboxStore`, `OutboxWriteOptions`, `OutboxClaimOptions`,
1041
- `OutboxAcknowledgeOptions`, `OutboxFailOptions`, `OutboxErrorInfo`,
1042
- `OutboxFailureContext`, `OutboxFailureDecision`, `OutboxFailurePolicy`;
1043
- `OutboxOwnershipError`, `InvalidOutboxEventError`. **Contract only** —
1044
- runtime (`EventOutbox`, `MemoryOutboxStore`, `MongoOutboxStore`,
1045
- `exponentialBackoff`) ships in `@classytic/arc/events` and
1046
- `@classytic/arc/events/mongo`. Domain packages implement `OutboxStore`
1047
- without peer-depping arc.
1048
- - **`/tenant`** — `TenantConfig` (with `strategy: 'field' | 'none' | 'custom'`
1049
- and optional `resolve(ctx)` for custom strategy), `TenantFieldType`,
1050
- `TenantStrategy`, `ResolvedTenantConfig`; `DEFAULT_TENANT_CONFIG`,
1051
- `resolveTenantConfig`. Field names match `@classytic/mongokit`'s
1052
- `MultiTenantOptions` so a resolved config threads into
1053
- `multiTenantPlugin({ ... })` without translation.
1054
- - **`/result`** — `Result<T, E>`; `ok`, `err`, `isOk`, `isErr`, `unwrap`,
1055
- `mapResult`, `mapError`.
1056
- - **`/brand`** — `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`,
1057
- `RequireKeys`, `OptionalKeys`, `NonEmptyArray`.
1058
- - **`/errors`** — `ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`.
1059
- - **`/split-allocation`** — `allocate(total, subjects, method)` returns a
1060
- `SplitResult` whose parts sum exactly to `total` via the largest-remainder
1061
- method. Methods: `equal`, `by-qty`, `by-weight`, `by-volume`, `by-value`,
1062
- `by-percent`. Deterministic — identical inputs produce identical outputs;
1063
- ties broken by input order. Handles negative totals by sign-flipping.
1064
- Exports: `allocate`, `isBalanced`, `SplitAllocationError`, types
1065
- `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult`,
1066
- `SplitAllocationErrorCode`.
1067
- - **`/approval`** — `ApprovalChain` value object with pure FSM helpers.
1068
- Supports sequential and parallel chains, per-step quorum
1069
- (`requiredApprovals`), threshold-gated skips. Any step-level rejection
1070
- rejects the chain; all steps `approved`|`skipped` approves it. Exports:
1071
- `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`,
1072
- `pendingSteps`, `isApproved`, `isRejected`, `isPending`, `decisionCount`,
1073
- `ApprovalError`, types `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`,
1074
- `Approver`, `ApprovalStatus`, `ChainOrder`, `ApprovalThreshold`,
1075
- `CreateChainInput`, `CreateStepInput`, `DecisionInput`, `ApprovalErrorCode`.
1076
- - **`/cadence`** — recurrence spec with pure UTC date arithmetic. Kinds:
1077
- `daily`, `weekly`, `monthly`, `yearly`, `cron` (opaque — host parses).
1078
- `dayOfMonth: 31` snaps to the last day of shorter months; Feb 29 snaps
1079
- to Feb 28 in non-leap years. Exports: `nextOccurrence`,
1080
- `occurrencesBetween`, `validateCadence`, `CadenceError`, types `Cadence`,
1081
- `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`,
1082
- `CronCadence`, `CadenceKind`, `IsoWeekday`, `CadenceErrorCode`.
1083
- - **`/hold`** — `HoldReason` + collection helpers. Host embeds
1084
- `HoldReason[]` on documents. Exports: `addHold`, `resolveHold`,
1085
- `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`,
1086
- `HoldError`, types `HoldReason`, `HoldActor`, `HoldActorKind`,
1087
- `AddHoldInput`, `ResolveHoldInput`, `HoldErrorCode`.
1088
- - **`/sla`** — target duration + breach-detection arithmetic. Policy is
1089
- carried but not executed — host reads `breachPolicy` (`warn` | `escalate`
1090
- | `block`) and acts. Exports: `breachedAt`, `remainingMs`, `elapsedMs`,
1091
- `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, types
1092
- `SLA`, `BreachPolicy`, `SLAErrorCode`.
1093
- - **`/person`** — person/contact shapes shared across order, crm, hr.
1094
- `PersonName`, `ContactInfo`, `EmergencyContact`, `Gender`; formatters
1095
- `formatFullName`, `formatDisplayName`.
1096
-
1097
- ### Design principles
1098
-
1099
- - Pure functions on plain data. No I/O, no Mongo, no events.
1100
- - Immutable return values. Helpers return new collections; originals are never mutated.
1101
- - Deterministic. Same inputs → same outputs. Clock sources injected
1102
- explicitly so tests don't flake.
1103
- - Errors are typed. Every module ships an `<Name>Error` class with a
1104
- discriminated `code: <Name>ErrorCode` string union.
1105
- - Subpath-imports only — no root barrel.
1106
-
1107
- ### Ownership direction
1108
-
1109
- Arc is the source of truth for event types. Primitives **mirrors** arc
1110
- (`@classytic/arc/events/EventTransport.ts`, `@classytic/arc/events/outbox.ts`)
1111
- verbatim for `EventMeta`, `DomainEvent`, `EventTransport`,
1112
- `DeadLetteredEvent`, `EventLogger`, `PublishManyResult`, and the full
1113
- `OutboxStore` contract surface. Helpers (`createEvent`, `createChildEvent`)
1114
- are allowed to be more defensive but match arc's signatures.
1115
-
1116
- Packages that peer-dep `@classytic/primitives` can write
1117
- `class FooOutboxRepository implements OutboxStore` without pulling arc into
1118
- their dependency graph; hosts import `EventOutbox` + `MongoOutboxStore` from
1119
- 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.30.0 — 2026-10-11
7
+
8
+ ### Added — `./durable-events`: the declaration and the ONE kernel dispatch
9
+ - `EventDurability`, `DurableEventSpec`, `FactExpectation`; `eventIdFor` / `durableEventMeta` (`${name}:v${version}:${occurrence}`); `requireDurableContext` + `NonDurablePublishError`; `UnmanagedSessionError`.
10
+ - `dispatchDomainEvent(channels, unit, event, durability)` saves the outbox row in the session, defers to commit, and acknowledges after an inline publish.
11
+ - `flushDeferredEvents` delivers a committed queue and acknowledges every delivered row in ONE `acknowledgeMany`.
12
+ Pinned by `tests/unit/durable-events.test.ts`.
13
+
14
+ ### Added — `./outbox`: `OutboxConflictError`, `outboxPayloadFingerprint`, optional `OutboxStore.acknowledgeMany`
15
+
16
+ ### ⚠ Changed
17
+ - `createChildEvent` no longer inherits the parent's `idempotencyKey`. A child is its own occurrence; an inherited key made a store drop the second of two child types.
18
+ - `MemoryOutboxStore.save`: the same id with the same payload is a no-op, and a different payload throws `OutboxConflictError`.
19
+
20
+ ## 0.29.0 — 2026-10-08
21
+
22
+ ### Added — `./signed-token`: ES256 compact JWS over WebCrypto
23
+ `signToken` / `verifyToken` for a server and an offline device, no dependency. `typ` is required on both
24
+ sides and a token without `exp` is refused; every failure is a named refusal (`bad_signature`,
25
+ `wrong_type`, `unknown_key`, `expired`, …), never a throw. Signing keys import non-extractable.
26
+ Pinned by `tests/unit/signed-token.test.ts` (the expiry rule is falsified).
27
+
28
+ ### Added — `itemStockOf` (`./item-stock`): one item's stock, the one summary every reader shows
29
+ `itemStockOf(item, rowOf, { defaultReorderPoint })` builds `{ quantity, inStock, lowStock, valueMinor?,
30
+ variants? }` from rows filed under stock keys (`stockKeyFor`). A row reached by both the item-level
31
+ and a variant lookup counts once; production's sole variant keys by the product id. It is pure and
32
+ browser-safe, so the server's catalog bridge and an offline till compute the same answer. Pinned by
33
+ `tests/unit/item-stock.test.ts`; collapsing the row de-duplication fails three of its cases.
34
+
35
+ ### Added — maker-checker on `ApprovalChain` (`./approval`)
36
+ `ApprovalChain.request` (`{ by, at, basis? }`) records who asked and what the approval covers.
37
+ `attachRequest(chain, request)` removes the requester from every step's approvers and throws
38
+ `NO_INDEPENDENT_APPROVER` when a step is left below its quorum. `applyDecision` throws
39
+ `SELF_APPROVAL` when the requester approves (rejecting their own request — a withdrawal — is
40
+ allowed). `applyDecision` and `skipStep` now preserve `request` and `notRequiredReason` instead of
41
+ rebuilding the chain from `{ order, steps, status }`.
42
+
43
+ ### Added — `diffEventSchema` (`./events`)
44
+ Compares two versions of an event payload's JSON Schema and marks each change breaking or not
45
+ for an existing CONSUMER (forward compatibility): a required field removed, a field made optional,
46
+ a widened type or enum break; additions and narrowing do not. Payloads are open for extension, so
47
+ `additionalProperties: false` is disregarded. Nested objects, array items and record values recurse.
48
+
49
+ ### Added — `createEventRouter` (`./events`)
50
+ An index over event subscriptions with `matchEventPattern`'s exact semantics — same values, same
51
+ order, deduped — in amortized O(1) per route: candidates are only the name's separator prefixes,
52
+ and results are memoized per name (bounded) until `add`/`remove`. Pinned by a property test
53
+ against the scan. Transports and graph analyses route through it instead of scanning.
54
+
55
+ ### Added — `readEnv`: strict configuration reads (`./environment`)
56
+ `readEnv(process.env)` → `bool | int | number | oneOf | list`, plus `InvalidEnvError`. Unset or
57
+ blank → the declared default; a value that is SET but unrecognised throws, naming the variable
58
+ and what it accepts. Booleans take `true/false`, `1/0`, `yes/no`, `on/off`; numbers take bounds.
59
+ A hand-rolled `=== 'true'` or `parseInt` widens a typo to the default and says nothing.
60
+
61
+ ### Added — placeholder phone, one definition (`./phone`)
62
+ `PLACEHOLDER_PHONE_PREFIX`, `placeholderPhone(id)`, `isPlaceholderPhone(v)`, `shownPhone(v)`.
63
+ A record whose `phone` is required and unique but unknown stores `pending_<id>`; the writer
64
+ (`@spinekit/order`) and every reader (be-prod party linking, the SDK, commerce-ui, pos-ui,
65
+ the storefront) now share this instead of five hand-written `startsWith('pending_')`.
66
+
67
+ ### Deprecated: `@classytic/primitives/idempotency` — moved to `@classytic/repo-core/idempotency`
68
+
69
+ The claim contract had no consumers here. It now lives beside `repo-core/lock` with its store
70
+ port, `runIdempotent`, the reference store and the conformance suite, so every persistence kit
71
+ implements it without depending on this package. Removed in the next minor.
72
+
73
+ ### Changed: `canonicalJson` refuses nesting deeper than 100 levels
74
+
75
+ `MAX_CANONICAL_DEPTH` (MongoDB's document limit). Deeper input throws `CanonicalizeError`
76
+ instead of recursing — a hostile input used to overflow the stack.
77
+
78
+ ### Added: `@classytic/primitives/line-settlement` (incl. `shareOfSettled`)
79
+
80
+ One definition of "how much of this document line is still open". A line's
81
+ quantity splits into kernel-named outcome buckets (fulfilled / returned /
82
+ received / written off …); `outstandingOf`, `settlementState`,
83
+ `documentSettlementState` and `settledEntirelyIn` derive the rest, and
84
+ `planSettlement` REFUSES a non-integer or over-outstanding quantity instead of
85
+ trimming it (`clampSettlement` exists for surfaces whose contract is "take what
86
+ fits"). Order, flow, purchase and transfer each derived this by hand; transfer
87
+ adopts it first.
88
+
89
+ ### Added: `OutboxClaimedEvent.attempts`, the durable attempt count
90
+
91
+ `OutboxFailureContext.attempts` was documented as "tracked in-process by the
92
+ relay; resets on restart", and that is exactly where a failure policy is wrong.
93
+ `attempts >= 5 → deadLetter` never fired for a poison event that crashes the
94
+ relay (it came back at 1 on every boot), and across N relays sharing a store it
95
+ meant 5×N deliveries. Stores already increment a per-row counter atomically
96
+ inside the claim; they had no way to report it.
97
+
98
+ `OutboxClaimedEvent` (the fenced-claim result) gains an optional
99
+ `attempts?: number`, which counts every claim including the one that returned it.
100
+ `MemoryOutboxStore` reports it. Optional and additive: stores that don't count
101
+ leave it absent, and relays fall back to their own count and take the larger of
102
+ the two.
103
+
104
+ ## 0.28.0
105
+
106
+ ### Added: a `pass` monetization kind (paid once, grants a term, never renews)
107
+
108
+ The union could say "charged once" (`one_time`, a single price, no span) and
109
+ "recurring" (`subscription`, priced spans that renew). It could not say
110
+ "prepaid term": a course access pass, a gym or transit pass, a parking permit,
111
+ a term licence. Hosts selling one reached for `subscription`, the only shape
112
+ that carried priced spans, and then had to explain at every read that it does
113
+ not actually renew. `toRevenueMonetizationType` would also have settled those
114
+ sales as recurring income, forecasting revenue nothing was scheduled to bill.
115
+
116
+ `PassMonetization { type: 'pass', passes: AccessPass[] }`, where an
117
+ `AccessPass` is a key, a label, a price, an optional "was" price, and a
118
+ `duration` + `durationUnit`. Shaped like `SubscriptionPlan` so a term means
119
+ the same thing everywhere, kept separate because a plan carries `trialDays`
120
+ and a renewal discount that a pass has none of.
121
+
122
+ Also: `isPassMonetization`, `defaultPassOf` (the cheapest term, ties broken on
123
+ the shorter SPAN so the headline is deterministic), and `approxTermDays` for
124
+ ordering and labels only. `unitPriceOf` returns the cheapest term's price for
125
+ a pass, `compareAtPriceOf` now returns the "was" of that SAME term so a card
126
+ can never strike one term's price through beside another's, and
127
+ `toRevenueMonetizationType('pass')` is `purchase`.
128
+
129
+ A perpetual sale stays `one_time`: "forever" is the absence of a term, not a
130
+ very long one.
131
+
132
+ ### Added — `CashDestination` on `./money`
133
+
134
+ `CASH_DESTINATIONS` (`'cash' | 'petty_cash'`), `isCashDestination` and
135
+ `cashDestinationLabel`: WHERE received cash landed, as an intent — never a
136
+ chart-of-accounts code. One vocabulary for a request body, a domain event, a
137
+ client SDK and a settlement dialog, so the accounting tier alone maps it to the
138
+ active chart and a renumber cannot reach any of them. Added because a client
139
+ carried a literal bank code the chart had already renumbered, and every
140
+ settlement it sent was refused.
141
+
142
+ ## 0.27.0
143
+
144
+ ### Added — `./csv` subpath: one CSV cell escaper for the whole fleet
145
+
146
+ `escapeCsvCell(value)` and `neutralizeCsvFormula(value)`. Six CSV producers
147
+ across ledger, the spine and the hosts each hand-rolled RFC 4180 quoting, and
148
+ not one neutralized a leading `= + - @ \t \r` — so a customer-supplied payment
149
+ reference or party name reached the accountant's spreadsheet as a live formula.
150
+ Quoting does not help: a spreadsheet strips the quotes and evaluates what is
151
+ left, which is why the two defences ship as one module.
152
+
153
+ The two functions are separable on purpose. A consumer holding a real CSV
154
+ writer (`csv-stringify`) takes `neutralizeCsvFormula` only, so its quoting is
155
+ not done twice; a hand-rolled `rows.map(...).join(',')` takes `escapeCsvCell`.
156
+
157
+ **Well-formed numbers are exempt.** Prefixing `-1500` would make every negative
158
+ amount text and break `SUM` in every finance export — a fix that quietly
159
+ corrupts the product is worse than the hole. The exemption requires the WHOLE
160
+ cell to be a number, so `-2+3+cmd|'/c calc'!A0` is still neutralized.
161
+
162
+ Not for a machine-to-machine upload contract with a fixed column spec (a
163
+ courier's bulk-import CSV): there the receiver is a parser, not a spreadsheet,
164
+ and an added `'` corrupts the payload.
165
+
166
+ ## 0.26.4
167
+
168
+ ### Added — `./price-basis` subpath: measured pricing across physical dimensions
169
+
170
+ New `PriceBasis` type (`FixedPriceBasis | MeasuredPriceBasis`) models how a quoted price spans a sellable quantity — flat fixed or per-unit-of-measure (mass, duration, volume, length, area). Factory functions `fixedPriceBasis()` and `measuredPriceBasis(dimension, qtyUnit, priceUnit, qtyPerPriceUnit)` construct the two shapes; `isPriceBasis()` is a structural guard for DB/wire boundaries. `resolvePriceBasisTotal(unitPriceMinor, quantity, basis?)` converts a minor-unit price × integer quantity to a final minor-unit total using half-even rounding (`roundedDiv` internally) and throws `PriceBasisError` on unsafe inputs or overflow.
171
+
172
+ ### Added — `./unit-cost-rate` re-exports `RoundingMode`
173
+
174
+ `RoundingMode` (`'half-even' | 'half-up'`) is now defined in the new internal `rounded-div.ts` and re-exported from `unit-cost-rate`. No breaking change — existing consumers who import `RoundingMode` from `@classytic/primitives/unit-cost-rate` continue to work.
175
+
176
+ ### Changed — `validateAddress` / `isAddressValid` accept `Address` directly
177
+
178
+ Both functions now accept `Address | PartialAddress | null | undefined`, removing the need for a cast when validating a fully-typed value object.
179
+
180
+ ## 0.26.3
181
+
182
+ ### Added — `PaymentProviderPort.wantsRawBody`
183
+
184
+ Optional `readonly wantsRawBody?: boolean` on the payment gateway port.
185
+ Providers that verify an HMAC signature (Stripe signs the bytes it transmitted)
186
+ must declare `true` — the transport then hands the raw body rather than the
187
+ parsed object. Absent / `false` preserves the historical behaviour (parsed
188
+ body), which is correct for manual, cash and terminal providers that sign
189
+ nothing.
190
+
191
+ ## 0.26.2
192
+
193
+ ### Added — `stockKeyFor` (`/item-identity`): one stock-key precedence for every package
194
+
195
+ The product-level stock key, with a single precedence:
196
+
197
+ 1. the variant's stamped `skuRef` — assigned once at birth, immutable
198
+ 2. a **sole** variant with no stamp → the product's `_id`, never the label
199
+ (legacy writers derived `variantSku || productId`, and for one variant that
200
+ key IS the `_id`)
201
+ 3. a **sibling** with no stamp → its label, which genuinely is its legacy key
202
+ 4. nothing addressable → the product's `_id`
203
+
204
+ The caller declares only what it HAS — `{ kind: 'sole' | 'label' | 'variant' }`
205
+ — so the sole-vs-sibling decision is internal and a caller cannot make it wrong.
206
+ That choice previously lived at every call site across three packages, in two
207
+ functions with different fallbacks; they disagreed on a sole, unstamped,
208
+ labelled variant, and the read then looked up a key no quant is stored under.
209
+
210
+ It lives here, below every domain, because `@spinekit/order` and
211
+ `@spinekit/catalog` must not import a peer domain — the rule sitting one layer
212
+ too high is exactly why each package re-derived it.
213
+
214
+ ## 0.26.1
215
+
216
+ - `./testing/stock-key-seam` — new subpath; `scanStockKeySeam`, `assertStockKeySeam`, `takesStockKeyFromLabel`, `STOCK_KEY_SEAM_FIXTURES` for conformance-scanning packages that resolve stock keys
217
+
218
+ ## [0.26.0] — 2026-08-27
219
+
220
+ ### Added
221
+
222
+ - **`./deadline` subpath** — `Deadline` value type + helpers for date-bounded
223
+ obligations (reservations, SLA windows, contract terms).
224
+ - **`./stock-level` subpath** — canonical stock-level representation and helpers.
225
+ - **`./item-facts` subpath** — cross-kernel item fact contract (weight, dimensions,
226
+ hazmat flags) that catalog declares and logistics/inventory reads.
227
+ - **`StockKey` branded type** (`./item-identity`) — prevents a bare merchandising
228
+ label or ObjectId being passed where a quant key is expected; the only source is
229
+ `stockKeyOf()` / `requireStockKey()`. Return types of both functions updated.
230
+ - **Upfront collection policy** (`./monetization`) — `UpfrontCollectionPolicy`
231
+ (`'full_payment' | 'deposit' | 'deferred'`) + `UPFRONT_COLLECTION_POLICIES`
232
+ const. Cross-kernel: catalog declares the policy, order turns it into tenders,
233
+ revenue settles them.
234
+
235
+ ## [0.25.0] - 2026-08-24
236
+
237
+ ### Added
238
+
239
+ - **`@classytic/primitives/item-identity` subpath** — `SkuFields`, `stockKeyOf`, `skuDisplay`. `sku` is a label; `skuRef` is the stock identity — the two fall back in opposite directions and getting one backwards type-checks while returning a plausible wrong string. `stockKeyOf` resolves `skuRef ?? sku ?? fallback` (the key to reserve, decrement and post against); `skuDisplay` resolves `skuLabel ?? sku ?? skuRef` (what a human reads). Canonicalises the per-package `variantSku || String(productId)` derivations into one place.
240
+
241
+ ## [0.24.0] - 2026-08-24
242
+
243
+ ### Added — `@classytic/primitives/address`: capture-format system
244
+
245
+ `AddressFormat`, `AddressFieldDescriptor`, `AddressFieldKey`, `PartialAddress`, `AddressFieldError` — a description of HOW a country collects a `ContactAddress`: field labels, order, requiredness, subdivision taxonomy hooks, and postal code pattern. Country packs (e.g. `@classytic/bd-areas/address-format`) live beside their taxonomies; this subpath owns only the generic shape.
246
+
247
+ `validateAddress(address, format)` — validates a partial address against a format; returns `AddressFieldError[]`. Enforces `required` on both canonical and extension fields identically, and checks `postalCodePattern` only when a value is given and a pattern is declared. `isAddressValid` and `describeAddressError` are convenience wrappers.
248
+
249
+ Every `key` in a descriptor is a field of `ContactAddress`; co-location with the value object is what makes a mismatch obvious instead of invisible.
250
+
251
+ ## [0.23.0] - 2026-08-15
252
+
253
+ ### Added — `./approval`: `notRequiredChain(reason)` + `isNotRequired(chain)`
254
+
255
+ The chain for a subject that needs **no** approval — zero steps, already resolved. The
256
+ explicit, greppable answer to "the policy engine ran and decided this document does not
257
+ require approval", which callers previously had no way to express.
258
+
259
+ This states a conclusion the module already reached rather than inventing one:
260
+ `computeChainStatus` folds an empty step list to `'approved'` — "nothing outstanding" is
261
+ what approved MEANS here, and that fold predates this function. `createChain` still throws
262
+ `EMPTY_STEPS`: building a ROUTING chain that routes nowhere is a different mistake and
263
+ stays refused. The two are deliberately not interchangeable, so reaching "approved with no
264
+ approvers" requires saying so by name.
265
+
266
+ **Why a chain rather than leaving the field unset.** Absence is the one input that occurs
267
+ by accident — a schema that never declared `approvals` strips it on write — and
268
+ `assertApproved` must keep treating that as `CHAIN_MISSING`. A present zero-step chain is
269
+ distinguishable from a lost chain (absent) *and* from a granted one (has steps and
270
+ decisions), so existing gates keep working through `isApproved` alone. No consumer needs a
271
+ second predicate, which is what would drift.
272
+
273
+ `isNotRequired` tests the STRUCTURE (`steps.length === 0`), not the new
274
+ `notRequiredReason` field: a subject schema that never declared that field drops it on
275
+ write, so a predicate reading it would answer differently before and after the save. The
276
+ reason is provenance for the audit trail only — pinned by a test that strips it and asserts
277
+ the predicate is unmoved.
278
+
279
+ **The hazard, stated in the docblock:** this makes `isApproved` true with no human
280
+ decision. It is correct only where a policy engine ASKED and got "no policy applies" —
281
+ never as a fallback for a lookup that failed or was never configured. First consumer:
282
+ `@classytic/arc-approval` 0.4.0 `whenNoPolicy: 'not-required'`.
283
+
284
+ ## [0.22.0] - 2026-08-12
285
+
286
+ ### Added — outbox fencing tokens (`claimPendingFenced`)
287
+
288
+ - **`OutboxStore.claimPendingFenced?`** (additive, feature-detected — mirrors repo-core's `LockAdapter.tryAcquireFenced`): same atomic semantics as `claimPending`, returning `OutboxClaimedEvent { event, fencingToken }`. The token is minted by the STORE per claim epoch — a takeover after lease expiry mints STRICTLY GREATER, so a stale ex-holder's `acknowledge`/`fail` (now accepting `fencingToken?`) throws `OutboxOwnershipError`. This closes the overlap that consumer-id ownership checks narrow but cannot close: holder ids recur; epochs do not.
289
+ - `MemoryOutboxStore` implements it; `claimPending` delegates to the fenced path (one claim implementation). Stale-ack rejection is mutation-proven.
290
+
291
+ ### Added — `./period`: `shiftPeriod(period, n)` — calendar arithmetic without instants
292
+
293
+ 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.
294
+
295
+ 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").
296
+
297
+ **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`.
298
+
299
+ ### Docs — `./environment` docblock refinement (no API change)
300
+
301
+ 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.
302
+
303
+ ## [0.21.0] - 2026-08-10
304
+
305
+ ### Added — `./environment`: canonical `NODE_ENV` classification
306
+
307
+ `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.
308
+
309
+ 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.
310
+
311
+ Exports: `classifyEnv`, `EnvClass`, `isProductionEnv`.
312
+
313
+ ### Added — `publint` gate on build
314
+
315
+ `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.
316
+
317
+ ## [0.20.0] - 2026-08-05
318
+
319
+ ### Added — `./period` OWNS timezone-aware period resolution (was two copies above the kernels)
320
+
321
+ `Period` was `{ year, month?, quarter? }` with no timezone and no resolver, so it
322
+ could not express "the business month of August in Asia/Dhaka". The resolver
323
+ existed TWICE, both ABOVE the kernels — `@spinekit/kit/period`
324
+ (`createBusinessCalendar`) and be-prod's `#lib/utils/business-date` — which meant
325
+ a kernel could reach neither and re-derived the boundary inline with `Date.UTC`.
326
+ Five silent UTC-month defects came out of that (`aggregateMonthlyVat`, the
327
+ sales-fact reconciler, and three in bd-tax Mushak).
328
+
329
+ This is a MOVE of the proven `createBusinessCalendar` logic, not a re-derivation:
330
+ same civil-date round-trip through `/timezone`, same half-open ranges, same
331
+ DST-exactness.
332
+
333
+ - **`Period.timezone?`** — the IANA zone a period is DEFINED in, so a persisted or
334
+ transported period keeps its own calendar.
335
+ - **`resolvePeriod(period, timezone?)`** → half-open `DateRange`. Throws when no
336
+ zone is available anywhere (never silently answers in UTC) and throws when the
337
+ period's zone and the caller's zone DISAGREE.
338
+ - **`resolveDay` / `resolveMonth` / `resolveQuarter` / `resolveYear` /
339
+ `resolveDateSpan` / `dayStart`** — the granularity-specific forms. All return
340
+ HALF-OPEN `[start, end)`, which tiles exactly.
341
+ - **`periodOf(instant, granularity, timezone)`** — the inverse: which filing
342
+ period an instant belongs to on the LOCAL calendar.
343
+ - **`parsePeriod` / `formatPeriod` / `granularityOf` / `periodTimeZone`** —
344
+ `'YYYY'` · `'YYYY-MM'` · `'YYYY-Qn'`. `parsePeriod` REFUSES a `'YYYY-MM-DD'`
345
+ rather than reading it as its month (a ~30× widening).
346
+ - **`inclusiveEnd(range)`** — `end - 1ms` for legacy `$lte` call sites, derived
347
+ from the resolved end so it stays DST-exact.
348
+ - **`PeriodError`** + **`PeriodErrorCode`** (`AMBIGUOUS_PERIOD` ·
349
+ `INCOMPLETE_RANGE` · `INVALID_FIELD` · `MISSING_TIMEZONE` · `TIMEZONE_CONFLICT`
350
+ · `INVALID_LABEL`).
351
+
352
+ A `Period` carrying two resolution shapes (`month` + `quarter`, or either with
353
+ `start`/`end`) now throws instead of preferring one.
354
+
355
+ ### Added — `./idempotency`: the claim + lease CONTRACT
356
+
357
+ Three kernels hand-rolled incompatible versions of one state machine — `cart`
358
+ (`in_flight`/`succeeded`/`failed`, the only one with crash-window leasing),
359
+ `catalog` offers (`pending`/`completed`, no lease), `contract` amendments
360
+ (implicit). Types + PURE helpers only; no persistence, no collection, no driver.
361
+
362
+ - **`ClaimOutcome = 'claimed' | 'replayed' | 'in_flight'`** — three-valued, and
363
+ `in_flight` is NOT a failure. Mapping a live concurrent lease onto an error
364
+ licenses a retry, which is a double-apply.
365
+ - **`IdempotencyClaim`** (`identity` · `requestFingerprint` · `state` ·
366
+ `leaseToken` · `leaseExpiresAt` · `attempts` · `result`), **`ClaimResult`**,
367
+ **`ClaimState`**, **`IdempotencyIdentity`**.
368
+ - **`decideClaim(existing, request)`** — the whole state machine as one pure
369
+ function. Throws `FINGERPRINT_MISMATCH` (same key, different body) and
370
+ `MISSING_REPLAY` (terminal record with no stored result) rather than answering.
371
+ - **`completeClaim` / `renewLease`** — both refuse when the caller lost its lease,
372
+ so a superseded attempt cannot overwrite the winner's result.
373
+ - **`identityKey`** (canonical-JSON composite, so a delimiter inside a scope value
374
+ cannot collide two identities), **`fingerprintRequest`**, **`newLeaseToken`**,
375
+ **`isLeaseExpired`**, **`holdsLease`**, **`isInFlight`**, **`DEFAULT_LEASE_MS`**.
376
+
377
+ ### Changed — BREAKING: five API decisions taken before spine wiring starts
378
+
379
+ - **`./cadence` — `Cadence.timezone` is now AUTHORITATIVE, not display-only.**
380
+ It was documented as decorative while `stepMonthly`/`stepYearly` used
381
+ `getUTCMonth()`/`getUTCDate()`, so `timezone: 'Asia/Dhaka'` + `dayOfMonth: 1`
382
+ with a midnight anchor placed every occurrence on the local **2nd**. All four
383
+ kinds now run on the zone's wall clock when one is set (daily advances LOCAL
384
+ days — 23h/25h across a DST transition), and stay UTC when it is absent.
385
+ `validateCadence` rejects an unresolvable zone with the new
386
+ `INVALID_TIMEZONE`. **Behavioural change for any persisted cadence that
387
+ already carries a `timezone`** — the occurrences move to where the field
388
+ always claimed they were.
389
+ - **`./approval` — `assertApproved` no longer passes on a `null` chain.** It now
390
+ throws the new `CHAIN_MISSING`. Absence is not approval, and a mongoose schema
391
+ that never declared `approvalChain` strips it on write, so the gate could not
392
+ tell "no approval configured" from "approval silently lost". The explicit
393
+ opt-out is the new **`assertApprovedIfPresent`**. The second parameter is now
394
+ an options object (`{ message }`), not a bare string.
395
+ - **`./payment-events` — every payload carries an `eventType` discriminant, and
396
+ there is a `payment.unknown` event.** `PaymentInitiatedPayload` and
397
+ `PaymentSucceededPayload` were structurally IDENTICAL with no discriminant on
398
+ the union, so a handler typed for "funds received" accepted "money in flight".
399
+ Adds **`PaymentUnknownPayload`** (the port contract's third outcome had no
400
+ event, so an unobserved result had to be forced into SUCCEEDED or FAILED),
401
+ **`PaymentEventPayloadMap`**, **`isPaymentEvent`**, **`isFundsReceived`**, and
402
+ three compile-time drift guards over the map.
403
+ - **`./currency` — `convertWithSnapshot` / `reverseWithSnapshot` take and return
404
+ `Money`.** A bare `number` meant nothing checked the amount was denominated in
405
+ `fx.sourceCurrency`; a USD→BDT snapshot applied to a EUR amount type-checked
406
+ and returned a plausible wrong number. Both now throw `CurrencyMismatchError`
407
+ on a mismatch, handle differing minor-unit exponents (USD 2 → JPY 0), and take
408
+ an explicit **`FxRounding`** (`'half-away-from-zero'` default · `'floor'` ·
409
+ `'ceil'`) instead of returning an unrounded float for each caller to round
410
+ differently.
411
+ - **`./money` — `Money.currency` is `CurrencyCode`, not `CurrencyCode | string`.**
412
+ A union with `string` collapses to `string`, so the brand was decoration on
413
+ the one type where it matters and `money()` validated nothing. `money()`,
414
+ `fromMajor()` and `sumMoney()` now validate and brand; `isMoney` format-checks
415
+ the code instead of `typeof === 'string'`. New **`currencyCode(value)`**
416
+ (throwing constructor) and **`InvalidCurrencyCodeError`**.
417
+ **`CurrencyMismatchError` is now DECLARED in `./currency`** (so the FX seam can
418
+ throw it without an ESM cycle) and re-exported from `./money`, so existing
419
+ imports are unaffected.
420
+
421
+ Migration: a `Money` object literal now needs a branded code —
422
+ `money(1999, 'USD')`, or `{ amount: 1999, currency: currencyCode('USD') }`.
423
+
424
+ ### Changed — six silent-permissiveness defects now FAIL instead of answering
425
+
426
+ Every item below previously returned a plausible-looking value where it should
427
+ have errored. All are behaviour changes for malformed input only; correct input
428
+ is unaffected. Falsifying tests live in `tests/unit/fail-loud-regressions.test.ts`.
429
+
430
+ - **`./period` — an Invalid Date no longer reports "no overlap".** `rangesOverlap`,
431
+ `isWithin` and `rangeDurationMs` throw the new `DateRangeError` on a non-finite
432
+ endpoint; `isDateRange` returns `false` for one. Every comparison against `NaN`
433
+ is `false`, so a corrupt range used to answer "no conflict" — the PERMISSIVE
434
+ answer for the three documented consumers (order booking collision, flow
435
+ reservation-window collision, promo campaign overlap), i.e. a silent double-book.
436
+ - **`./currency` — the ISO 4217 exponent table is now exhaustive.** All 0-decimal
437
+ (`BIF CLP DJF GNF ISK KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF`), 3-decimal
438
+ (`BHD IQD JOD KWD LYD OMR TND`) and 4-decimal (`CLF UYW`) currencies are listed,
439
+ so `minorUnitFactor`'s `?? 100` fallback is reachable only by a code that is not
440
+ a real ISO currency. Previously `fromMajor(1500, 'GNF')` stored 150 000 minor
441
+ units — a 100× error that looks like a number. New `isKnownCurrency(code)`
442
+ distinguishes "listed" from "assumed" for boot-time config validation.
443
+ - **`./proration` — a missing allocated part throws instead of becoming `0`.** The
444
+ `?? 0` fallbacks in `splitByPeriodFraction` / `allocateMoneyByFraction` are now
445
+ `ProrationError('ALLOCATION_INVARIANT')`. A zero credit and a genuinely
446
+ fully-consumed period were indistinguishable downstream.
447
+ - **`./state-machine` — `assertAndClaim` rejects an EMPTY `from` list.**
448
+ `validSources(to)` returns `[]` for an unreachable status, and the documented
449
+ `from: machine.validSources(x)` idiom then ran the assert loop zero times and
450
+ handed the CAS an empty allow-list. An empty allow-list must never mean "any".
451
+ - **`./timezone` — the offset regex is anchored.** An offset label the runtime
452
+ emits in an unexpected shape (e.g. an unpadded `GMT+5:30`) matched the optional
453
+ bare-`GMT` branch and resolved to **UTC**, silently shifting every business-day
454
+ boundary derived from it. Unrecognised labels now throw; `\d{1,2}` accepts the
455
+ unpadded variant rather than mis-reading it.
456
+ - **`./canonical` — class instances are rejected, not collapsed.** A
457
+ `Types.ObjectId`, `Buffer` or hydrated Mongoose subdocument has no own
458
+ enumerable keys, so it digested as `{}` and two manifests referencing DIFFERENT
459
+ documents hashed identically — the integrity check reported "unchanged".
460
+ `canonicalJson` now throws `CanonicalizeError` for any object whose prototype is
461
+ neither `Object.prototype` nor `null`. Plain objects, null-prototype objects,
462
+ arrays and `Date` are unaffected.
463
+
464
+ ### Changed — the publish smoke gate now sweeps the whole export map
465
+
466
+ `tests/smoke/smoke.mjs` derives its subpath list from `package.json#exports`
467
+ (44/44) instead of a hand-maintained list that covered 23, and asserts each entry
468
+ declares both `types` and `default` and that both files exist. The gate runs in
469
+ `prepublishOnly`; previously a subpath that lost its tsdown entry would have
470
+ published an export map pointing at a missing file with the gate printing OK.
471
+
472
+ ## [0.19.0] - 2026-08-01
473
+
474
+ ### Added — `./monetization`: canonical pricing classification contract
475
+
476
+ - **`@classytic/primitives/monetization`** — the single home for how a product
477
+ is sold and priced, so the classification survives end-to-end across catalog,
478
+ revenue, order, and entitlement kernels without each maintaining its own enum.
479
+ - **`Monetization`** discriminated union — `FreeMonetization | OneTimeMonetization |
480
+ SubscriptionMonetization | BundleMonetization | UsageMonetization`, each
481
+ carrying its own nested pricing shape.
482
+ - **`MONETIZATION_KINDS`** + **`MonetizationKind`** + **`isMonetizationKind`** —
483
+ exhaustive kind list with runtime guard.
484
+ - Type-guard predicates: **`isFreeMonetization`**, **`isOneTimeMonetization`**,
485
+ **`isSubscriptionMonetization`**, **`isBundleMonetization`**,
486
+ **`isUsageMonetization`**.
487
+ - Supporting types: `PriceTier`, `OneTimePricing`, `SubscriptionPlan`,
488
+ `UsageTier`, `UsageRating`, `DurationUnit`, `MoneyByCurrency`.
489
+
490
+ ### Added — `./proration`: pure proration arithmetic
491
+
492
+ - **`@classytic/primitives/proration`** — timezone-agnostic, policy-free
493
+ proration math. Holds no commercial policy (no upgrade/downgrade semantics,
494
+ no discount or tax logic) — those live in the contract kernel.
495
+ - **`periodProgress(input)`** — fraction of a billing period consumed given
496
+ start, end, and current instants as `Date`. Returns `PeriodFraction`.
497
+ - **`splitByPeriodFraction(amount, fraction, granularity?)`** — split a `Money`
498
+ amount into consumed/remaining parts; `granularity: 'whole_day'` (default) or
499
+ `'exact'`.
500
+ - **`allocateMoneyByFraction(amount, fraction)`** — proportional allocation
501
+ using the largest-remainder allocator so `consumed + remaining === amount`
502
+ exactly (no penny drift).
503
+ - **`ProrationError`** + **`ProrationErrorCode`** (`INVALID_PERIOD` |
504
+ `INVALID_FRACTION`) — typed errors for out-of-range inputs.
505
+ - Supporting types: `PeriodFraction`, `PeriodProgressInput`,
506
+ `ProrationGranularity`.
507
+
508
+ ## [0.18.0] - 2026-07-29
509
+
510
+ ### Added — `./subject`: `SubjectRef` polymorphic identity pointer
511
+
512
+ - **`@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.
513
+ - **`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.
514
+ - **`SubjectRef`** type exported from `@classytic/primitives/subject`.
515
+
516
+ ### Added — `./state-diagram`: Mermaid diagram from a `StateMachine`
517
+
518
+ - **`@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.
519
+ - **`StateDiagramOptions`** — `title?`, `highlight?` (mark specific states), `noteOn?` (per-state annotations).
520
+
521
+ ### Added — `./suspension`: time-bounded suspension/pause policy
522
+
523
+ - **`@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).
524
+ - **`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.
525
+ - **`SuspensionPolicy`** — `maxDaysPerPeriod`, `minDurationDays`, `period` ('annual' | 'rolling').
526
+ - **`SuspensionEntry`** — a historical pause record (`startedAt`, `endedAt?`, `reason?`).
527
+ - **`SuspensionResult`** — `autoResumeAt`, `daysUsed`, `daysRemaining`, `status`.
528
+
529
+ ### Added — `OutboxStore.transactionalSave` (`/outbox`)
530
+
531
+ - **`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.
532
+ - **`MemoryOutboxStore.transactionalSave = false`** — explicitly declared; an in-memory store cannot participate in a Mongo transaction, so its write survives a rollback.
533
+
534
+ ### Changed — `shelf-life.ts` deduplicates `addDays`
535
+
536
+ - `addDays` is now imported from `./calendar` instead of being re-defined inline. No behavior change.
537
+
538
+ ## [0.16.0] - 2026-07-27
539
+
540
+ ### Added — `assertApproved` + `CHAIN_INCOMPLETE` (`/approval`)
541
+
542
+ - **`ApprovalErrorCode` gains `'CHAIN_INCOMPLETE'`** — the single canonical
543
+ error code for "finalize/commit attempted before the chain reached `approved`".
544
+ Hosts route it through `rethrowApprovalError` to the `approval.chain_incomplete`
545
+ wire code so all commit/post/finalize boundaries raise the same structured error.
546
+ - **`assertApproved(chain, message?)`** — canonical finalize-time gate: asserts
547
+ the chain has reached `approved`, else throws `ApprovalError('CHAIN_INCOMPLETE')`.
548
+ A `null`/absent chain passes (no chain required). Use at every journal-entry
549
+ post, PO approve, transfer dispatch, etc. to replace per-domain hand-rolled
550
+ divergent codes with one definition.
551
+
552
+ Purely additive — no existing exports changed.
553
+
554
+ ## [0.15.0] - 2026-07-25
555
+
556
+ ### Added — `/retention`: `PurgeEvidence` value object
557
+
558
+ - **New `@classytic/primitives/retention` subpath** exporting **`PurgeEvidence`**
559
+ — the persisted evidence record for a GDPR / data-retention purge operation
560
+ (proof of what was purged, over what scope, by whom, why, and whether
561
+ analytical measures were retained). Shape: `{ id, subject: { ref, model? },
562
+ scope, strategy: 'hard'|'soft'|'anonymize', measuresRetained, processed,
563
+ occurredAt, actor: { ref, kind: 'user'|'system'|'service' }, reason,
564
+ legalBasis?, status: 'completed'|'partial'|'failed', operationId?,
565
+ startedAt?, completedAt?, results?: PurgeResourceResult[],
566
+ verification?: PurgeVerificationSummary }`.
567
+ - **`createPurgeEvidence(input)`** — pure builder (auto-fills `id` + `occurredAt`
568
+ when absent, defaults `status` to `'completed'`). **`isPurgeEvidence(value)`**
569
+ type guard + **`assertPurgeEvidence(value)`** throwing assertion.
570
+
571
+ ### Added — `/canonical`: strict deterministic JSON + integrity digest
572
+
573
+ - **New `@classytic/primitives/canonical` subpath** — `canonicalJson(value)`
574
+ produces a deterministic string (keys sorted recursively, `Date` → `{"$date":"<iso>"}`,
575
+ strict rejection of `undefined`/`NaN`/`BigInt`/`Map`/`Set`/cycles with
576
+ `CanonicalizeError`). **`sha256Hex(input)`** and **`canonicalDigest(value)`**
577
+ (= `sha256Hex(canonicalJson(value))`) — integrity checksums, not tamper-proof
578
+ seals. Uses `node:crypto` directly (same precedent as `/otp`).
579
+
580
+ ### Added — `/event-infra`: `propagateHandlerErrors` option
581
+
582
+ - **`InProcessEventBusOptions.propagateHandlerErrors?: boolean`** (default
583
+ `false` — existing error-isolating behavior unchanged). When `true`, `publish`
584
+ runs handlers sequentially and rethrows the first handler error to the
585
+ publisher — for single-consumer projection/relay lanes where a failed handler
586
+ must not be silently acked. `publishMany` maps each event's failure into its
587
+ `PublishManyResult` entry instead of rejecting the batch.
588
+
589
+ ## [0.13.0] - 2026-07-22
590
+
591
+ ### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
592
+
593
+ - **`@classytic/primitives/unit-cost-rate`** — fractional per-unit monetary
594
+ rates stored as scaled integers (`scaledAmount = minorPerUnit × RATE_SCALE`,
595
+ `RATE_SCALE = 1_000_000`). Solves the WAC / FIFO valuation problem where
596
+ dividing a total by a fractional quantity yields a sub-minor-unit rate
597
+ (100 paisa ÷ 3 units = 33.333… paisa/unit) that must not lose precision.
598
+ Exports:
599
+ - `unitCostRate(minorPerUnit, currency)` — direct rate constructor
600
+ - `unitCostRateFromTotal(totalMinor, quantity, currency)` — WAC/FIFO
601
+ constructor; bigint-safe, QTY_SCALE grid for fractional quantities
602
+ - `extendedAmount(rate, qty, mode?)` — the ONE rate→total rounding boundary;
603
+ bigint multiplication to keep large amounts exact; default `'half-even'`
604
+ (banker's rounding), `'half-up'` available for legacy parity
605
+ - `rateMinorPerUnit(rate)` — display helper (fractional number, NOT for math)
606
+ - `isUnitCostRate(value)` — structural type guard
607
+ - `UnitCostRate<C>` interface, `UnitCostRateError`, `UnitCostRateErrorCode`,
608
+ `RoundingMode`, `RATE_SCALE`
609
+ - For distributing a top-down charge across lines with no lost minor unit,
610
+ compose with `allocate` from `@classytic/primitives/split-allocation`.
611
+
612
+ ### Changed — `/outbox` is now the canonical contract owner
613
+
614
+ - **`@classytic/primitives/outbox` owns the transactional-outbox contract.**
615
+ Previously this file was documented as a mirror of `@classytic/arc`'s copy
616
+ ("arc is source of truth, keep bit-identical") while arc's docs said the
617
+ opposite — a manual-sync drift risk. Settled: primitives owns pure
618
+ cross-package contracts (events + outbox); arc owns runtime behavior
619
+ (`EventOutbox` relay, stores, backoff, Fastify integration) and, from
620
+ arc 2.24+, imports + re-exports this contract instead of duplicating it.
621
+ - **Cross-package `instanceof` now works.** Because arc re-exports these exact
622
+ `OutboxOwnershipError` / `InvalidOutboxEventError` classes, a store built
623
+ against primitives throws the same class identity arc's relay catches.
624
+ Under the old duplication, arc's `instanceof` checks silently missed
625
+ primitives-thrown ownership errors.
626
+ - No export names or signatures changed — contract surface is identical.
627
+
628
+ ## [0.11.0] - 2026-07-08
629
+
630
+ ### Added — eKYC vocabulary + secure OTP primitive
631
+
632
+ - **`@classytic/primitives/identity-verification`** — cross-package eKYC data shapes:
633
+ `IdentityDocumentType`, `VerificationCheck`, `VerificationLevel`, `SubjectKind`,
634
+ `IdentitySubject`, `DocumentReference`, `IdentityProviderCapabilities`,
635
+ `VerificationStatus`, `CheckResult`, `CreateVerificationParams`,
636
+ `VerificationSession`, `VerificationWebhookEvent`. Pure helpers: `isVerified`,
637
+ `checksSatisfy`, `deriveStatus`. The identity twin of `payment-gateway` — shapes
638
+ here, contracts in engines, provider implementations anywhere. Serves `@classytic/esign`
639
+ (IdentityBridge port), `@classytic/payee` (ComplianceBridge), and PSP merchant KYC.
640
+ - **`@classytic/primitives/otp`** — secure OTP challenge lifecycle. Guarantees: code
641
+ never persisted (only HMAC-SHA256 digest stored), constant-time `timingSafeEqual`
642
+ comparison, rejection-free `randomInt` CSPRNG (no modulo bias), bounded attempt
643
+ counter, TTL expiry, single-use verified state. Exports: `createOtpChallenge`,
644
+ `verifyOtpChallenge`, `isOtpVerified`, `isOtpActive`. Delivery stays in
645
+ `@classytic/notifications` — this primitive owns only the verify half.
646
+
647
+ ## [0.9.1] - 2026-07-04
648
+
649
+ ### Added
650
+
651
+ - **`CURRENCY_PATTERN` exported from `/currency`** — the ISO 4217 regex
652
+ (`/^[A-Z]{3}$/`) behind `isCurrencyCode` / `toCurrencyCode`. Consumers
653
+ embed the SAME pattern in JSON-Schema-representable validators (zod
654
+ `.regex`, Mongoose `match`, OpenAPI `pattern`) instead of hand-rolling
655
+ copies — `.refine(isCurrencyCode)` validates at runtime but is dropped
656
+ by `z.toJSONSchema`, silently losing the constraint from generated API
657
+ docs.
658
+
659
+ ## [0.9.0] - 2026-07-02
660
+
661
+ ### Added — `/timezone`: IANA resolution + civil dates (the door for `/calendar`'s escape hatch)
662
+
663
+ `/calendar` owns wall-clock ARITHMETIC over a fixed `UtcOffsetMinutes` and
664
+ tells hosts to "resolve the offset for the instant and pass it in" — but the
665
+ stack had no shared resolver, so consumers hand-rolled `Intl.DateTimeFormat`
666
+ (three divergent copies found: catalog ×2, be-prod POS). `/timezone` is that
667
+ resolver — zero-dep (Intl IS the ICU tz database), Temporal-shaped names for
668
+ a future mechanical migration:
669
+
670
+ - **`zoneOffsetMinutes(instant, zone)`** — per-instant, DST-exact offset;
671
+ composes directly with `/calendar`: `startOfMonth(i, zoneOffsetMinutes(i, z))`.
672
+ - **`localTimeParts(instant, zone)`** — `{ isoWeekday, hour, minute,
673
+ minutesOfDay }` in the zone; what pricing windows / slot grids /
674
+ working-hours checks evaluate against (canonicalizes catalog's resolvers).
675
+ - **`CivilDate`** (branded `'YYYY-MM-DD'`) + `civilDate` / `isCivilDate` /
676
+ `civilDateOf(instant, zone)` / `civilDateToInstant(cd, zone, time?)`
677
+ (two-pass DST-safe) / `addCivilDays` / `civilDaysBetween` — the type for
678
+ hotel nights, POS business dates, VAT filing years. Lexicographic ==
679
+ chronological, so it's a range-queryable Mongo string key.
680
+ - **`isValidTimeZone(zone)`** — boot-time host-config validation;
681
+ **`listTimeZones()`** + **`zoneOffsetLabel(instant, zone)`** — the data
682
+ source + label for Google-Calendar-style zone pickers, straight from ICU
683
+ (`Intl.supportedValuesOf`), no bundled list to go stale.
684
+ - `TimeZoneError`; formatter instances cached per zone.
685
+
686
+ No arithmetic here (that stays in `/calendar`) — resolution only.
687
+
688
+ ## [0.7.2] - 2026-05-30
689
+
690
+ ### Added — `BankTransaction` per-account routing + pending lifecycle fields
691
+
692
+ Three additive optional fields on `BankTransaction` (`/bank-transaction`):
693
+
694
+ - **`sourceAccountId?: string`** — the vendor-side account this row belongs to
695
+ (Plaid `account_id`, Xero `BankAccount.AccountID`). Sync feeds return
696
+ transactions spanning multiple accounts in one batch; this lets consumers
697
+ route each row to the right account per-transaction (multi-account sync)
698
+ instead of forcing the whole batch onto one account. Statement formats
699
+ (single-account files) leave it undefined — the account lives on
700
+ `BankStatement.account`.
701
+ - **`pending?: boolean`** — the entry hasn't posted/cleared yet (real-time
702
+ feeds like Plaid `pending: true`; statement formats leave it undefined).
703
+ Consumers must treat pending rows as provisional — don't reconcile/post
704
+ them as final.
705
+ - **`supersedesExternalId?: string`** — set on a POSTED row that replaces an
706
+ earlier PENDING one, carrying the pending row's `externalId` (Plaid
707
+ `pending_transaction_id`). Pending and posted rows have different
708
+ `externalId`s, so consumers MUST use this back-pointer to drop/replace the
709
+ superseded pending row on ingest — otherwise both persist as duplicates.
710
+
711
+ Backward-compatible (both optional). Closes a fidelity gap where Plaid's
712
+ pending state was dropped at the canonical boundary, so downstream consumers
713
+ (`@classytic/fin-io` Plaid mapper, `@classytic/revenue` import) couldn't
714
+ distinguish provisional rows or dedupe pending→posted.
715
+
716
+ ## [0.7.1] - 2026-05-26
717
+
718
+ ### Added — workflow + scheduling + composition primitives
719
+
720
+ Four new flat subpaths (additive):
721
+
722
+ - **`/status-history`** — append-only status transition log primitive.
723
+ - **`/condition`** — declarative condition expressions for workflow gates.
724
+ - **`/mixin`** — composition helpers for primitive object assembly.
725
+ - **`/sla-policy`** — SLA policy definitions paired with the existing `/sla` runtime.
726
+
727
+ ### Added — payment event coverage
728
+
729
+ Expanded `PAYMENT_EVENT_TYPE` catalogue to cover auth/capture and dispute
730
+ lifecycles: `AUTHORIZED`, `CAPTURED`, `AUTH_VOIDED`, `DISPUTED`,
731
+ `DISPUTE_WON`, `DISPUTE_LOST`, `SETTLED`.
732
+
733
+ `PaymentRefundedPayload` and `PaymentReversedPayload` now carry
734
+ `originalAmount` (and `reversedAmount` / `isPartial` on reversed) so
735
+ consumers can detect partial vs full operations without a follow-up
736
+ Payment lookup.
737
+
738
+ ## [0.7.0] - 2026-05-26
739
+
740
+ ### Added — payment domain primitives
741
+
742
+ Three new subpaths shared across every payment-aware package
743
+ (`@classytic/invoice`, `@classytic/revenue`, `@classytic/order`, future
744
+ POS / AP / subscription packages). Each ships under a flat subpath so
745
+ consumers tree-shake what they don't import.
746
+
747
+ - **`/payment-method-kind`** — `PaymentMethodKind` universal vocabulary
748
+ (kind vs host-registered `methodCode`) so packages can speak `kind`
749
+ while hosts speak `code`.
750
+ - **`/payment-allocation-status`** — orthogonal allocation +
751
+ reconciliation status enums for payment legs (`unallocated`,
752
+ `partial`, `allocated`, `over_allocated`, plus reconciliation states).
753
+ - **`/payment-events`** — pure TS payload contracts for payment domain
754
+ events. Wire-only (no Zod, no runtime). Anchored on `Money` +
755
+ `PaymentMethodKind`; causation/correlation stay on `EventMeta`.
756
+
757
+ No breaking changes.
758
+
759
+ ## [0.6.0] - 2026-05-17
760
+
761
+ ### Added — five new primitives for CRM / workflow domains
762
+
763
+ Five new primitives covering gaps the existing surface didn't address.
764
+ Each ships under its own flat subpath import (no breaking changes).
765
+
766
+ - **`/phone`** — `PhoneNumber` value object + `parsePhone(input)` that
767
+ normalizes free-form input to E.164, captures the country calling code
768
+ as a discrete field, and reports failures via `Result<PhoneNumber,
769
+ PhoneError>`. Zero external deps; ITU-T E.164 prefixes inlined. Hosts
770
+ that want region-of-issue / line-type detection layer on top with
771
+ libphonenumber-js.
772
+
773
+ - **`/status-history`** — Generic, immutable status-change log:
774
+ `StatusChangeEntry<TStatus>`, `appendStatus()`, `timeInStatus()`,
775
+ `lastTransitionTo()`. Captures `durationInPriorMs` at write time so
776
+ funnel-velocity dashboards ("avg time in Qualification") work without
777
+ re-walking timestamps. Pairs naturally with `/state-machine`.
778
+
779
+ - **`/condition`** — Tiny JSON-serializable predicate DSL: `FieldCondition`
780
+ (`{ field, op, value }`) composed via `AllCondition` / `AnyCondition`
781
+ / `NotCondition`, plus a total `evaluate(condition, target)` and a
782
+ `validateCondition()` for boot-time schema checks. Powers SLA scope
783
+ filters, drip "if opened/clicked" branches, automation triggers, and
784
+ permission row-scopes — anywhere a predicate needs to round-trip
785
+ through a database.
786
+
787
+ - **`/mixin`** — Additive type composition without subclassing:
788
+ `withMixin<T, K, M>()` / `getMixin()` / `hasMixin()` / `withoutMixin()`.
789
+ Lets a `Contact` carry per-domain payloads (`mixins.customer`,
790
+ `mixins.lead-fit`) on a typed `mixins` namespace without bloating the
791
+ base type. Plain object composition — no decorators, no global
792
+ hierarchy, no proxies.
793
+
794
+ - **`/sla-policy`** — Higher-order SLA layered on the existing `/sla`:
795
+ priority matrix (`urgent` / `high` / `normal` → durations), first-
796
+ response vs rolling-response semantics, and working-hours window
797
+ (weekdays + start/end minute + holidays). `defineSLAPolicy()` validates
798
+ at boot; `evaluateSLAStatus()` produces `{ kind, responseBy,
799
+ remainingMs, breached }` from a policy + per-entity inputs. The
800
+ existing simple `/sla` interface is unchanged — `sla-policy` derives
801
+ concrete `SLA` instances from a policy + priority and delegates to it.
802
+
803
+ ### Changed — source-tree reorganization (no public-surface impact)
804
+
805
+ The 24 primitive source files moved from a flat `src/*.ts` layout into
806
+ six category folders so the package is self-documenting at the file
807
+ system level:
808
+
809
+ ```
810
+ src/
811
+ money/ money, currency, split-allocation, bank-transaction, payment-gateway
812
+ identity/ person, address, phone
813
+ scheduling/ period, cadence, sla, sla-policy
814
+ workflow/ state-machine, status-history, approval, hold, condition
815
+ events/ events, outbox
816
+ composition/ mixin, reference, context, brand, result
817
+ ```
818
+
819
+ **Public subpath imports are unchanged** — `@classytic/primitives/money`,
820
+ `@classytic/primitives/sla`, etc. still resolve. tsdown emits each entry
821
+ to `dist/<name>.mjs` regardless of source nesting. Existing consumers
822
+ upgrade with `npm install @classytic/primitives@^0.6.0` and a no-op
823
+ diff.
824
+
825
+ ### Dependencies
826
+
827
+ Still zero runtime deps. Node 22+.
828
+
829
+ ## [0.5.0] - 2026-05-05
830
+
831
+ ### Added — `/bank-transaction` subpath
832
+
833
+ Canonical bank-feed shapes shared across `@classytic/fin-io` (parsers
834
+ produce these), `@classytic/revenue` (`transactionRepository.import()`
835
+ consumes them), and `@classytic/ledger` (`bankStatementMapper`
836
+ consumes them). Single source of truth for the cross-package
837
+ vocabulary; eliminates the structural mirrors fin-io and revenue
838
+ were carrying.
839
+
840
+ Exports: `BankTransaction`, `BankCounterparty`, `BankAccount`,
841
+ `BankStatement`, `BankStatementSource`, `BankImportReport`,
842
+ `BankImportRowError`. All anchored on `Money` from `/money` (integer
843
+ minor units, `number`).
844
+
845
+ ### Added — `/payment-gateway` subpath
846
+
847
+ Canonical payment-gateway data shapes. **Lets any provider package
848
+ (Stripe, Razorpay, SSLCommerz, PayPal, bKash, Nagad, manual, …)
849
+ implement against primitives' types without depending on revenue's
850
+ heavyweight runtime.** Provider authors peer-dep on
851
+ `@classytic/primitives` only.
852
+
853
+ Exports: `CreateIntentParams`, `PaymentIntent`, `PaymentResult`,
854
+ `RefundResult`, `WebhookEvent`, `ProviderCapabilities`. All `amount`
855
+ fields are `Money` from `/money` (no flat `amount: number, currency:
856
+ string` pairs — currency travels inside Money).
857
+
858
+ The `PaymentProvider` abstract class — the *contract* every provider
859
+ must satisfy — stays in `@classytic/revenue`. Revenue is the data
860
+ engine that consumes providers; provider packages live elsewhere.
861
+
862
+ ### Architectural intent
863
+
864
+ Revenue 3.0 + primitives 0.5 + fin-io 0.3 establish a clean
865
+ three-tier seam:
866
+
867
+ 1. **primitives** — pure data shapes (no runtime, no deps). Owns
868
+ `Money`, `BankTransaction`, `PaymentIntent`, every cross-
869
+ package vocabulary type.
870
+ 2. **revenue** — the engine. Owns the abstract contracts
871
+ (`PaymentProvider`, `BankFeedProvider`), state machines, repos.
872
+ Consumes primitives shapes.
873
+ 3. **provider packages** (`revenue-stripe`, `revenue-razorpay`,
874
+ `@classytic/fin-io/adapters/revenue`, …) — implementations.
875
+ Peer-dep on primitives. Optional peer-dep on revenue for the
876
+ abstract base class.
877
+
878
+ This eliminates the cycle where every provider package transitively
879
+ pulled mongoose + mongokit just to know what a `PaymentIntent`
880
+ looked like. Tree-shaking, build times, and dep-graph honesty all
881
+ improve.
882
+
883
+ ### Migration
884
+
885
+ Consumers MUST import from primitives subpaths. No re-exports from
886
+ revenue or fin-io (PACKAGE_RULES P2):
887
+
888
+ ```ts
889
+ // Before
890
+ import type { PaymentIntent, CreateIntentParams } from '@classytic/revenue';
891
+ import type { CanonicalTransaction, Money } from '@classytic/fin-io';
892
+
893
+ // After
894
+ import type {
895
+ PaymentIntent,
896
+ CreateIntentParams,
897
+ } from '@classytic/primitives/payment-gateway';
898
+ import type { BankTransaction } from '@classytic/primitives/bank-transaction';
899
+ import type { Money } from '@classytic/primitives/money';
900
+ ```
901
+
902
+ Provider helper classes (`PaymentIntent`, `PaymentResult`,
903
+ `RefundResult`, `WebhookEvent`) are deleted from revenue — they were
904
+ trivial wrappers around plain objects. Provider implementations now
905
+ return plain object literals matching the interface.
906
+
907
+ `CreateIntentParams.amount` is now `Money` (`{ amount: number,
908
+ currency: string }`), not the flat `amount + currency` pair. Revenue's
909
+ repository wraps internally:
910
+
911
+ ```ts
912
+ const intent = await provider.createIntent({
913
+ amount: { amount: params.amount, currency },
914
+ metadata: params.metadata,
915
+ ...params.paymentData,
916
+ });
917
+ ```
918
+
919
+ ## [0.3.1] - 2026-05-02
920
+
921
+ ### Added — state-machine ↔ mongokit/sqlitekit integration
922
+
923
+ State-machine primitives now compose directly with the kit-level CAS surface shipped in mongokit 3.13 and the sqlitekit follow-up. Three additions:
924
+
925
+ - **`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 })`.
926
+ - **`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.
927
+ - **`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.
928
+
929
+ ```ts
930
+ import { defineStateMachine, assertAndClaim } from '@classytic/primitives/state-machine';
931
+
932
+ const ORDER_MACHINE = defineStateMachine<OrderStatus>({
933
+ name: 'Order',
934
+ transitions: {
935
+ draft: ['approved', 'cancelled'],
936
+ approved: ['shipped', 'cancelled'],
937
+ shipped: [],
938
+ cancelled: [],
939
+ },
940
+ });
941
+
942
+ // Single-source:
943
+ const updated = await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
944
+ from: 'draft',
945
+ to: 'approved',
946
+ patch: { approvedAt: new Date() },
947
+ options: { organizationId: ctx.organizationId, session },
948
+ });
949
+
950
+ // Multi-source via reverse adjacency — "cancel from any legal predecessor":
951
+ await assertAndClaim(ORDER_MACHINE, orderRepo, orderId, {
952
+ from: ORDER_MACHINE.validSources('cancelled'), // ['draft', 'approved']
953
+ to: 'cancelled',
954
+ patch: { cancelledAt: new Date() },
955
+ options: { organizationId: ctx.organizationId },
956
+ });
957
+ ```
958
+
959
+ #### `ClaimableRepo<TDoc>` — structural dependency on the kit
960
+
961
+ `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).
962
+
963
+ ```ts
964
+ export interface ClaimableRepo<TDoc> {
965
+ claim(
966
+ id: string,
967
+ transition: {
968
+ field?: string;
969
+ from: unknown | readonly unknown[];
970
+ to: unknown;
971
+ where?: Record<string, unknown>;
972
+ },
973
+ patch?: Record<string, unknown>,
974
+ options?: Record<string, unknown>,
975
+ ): Promise<TDoc | null>;
976
+ }
977
+ ```
978
+
979
+ #### Why a 0.3.1 (not 0.4.0)
980
+
981
+ 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.
982
+
983
+ ## [0.3.0] - 2026-04-29
984
+
985
+ ### Removed — `/errors` subpath (BREAKING)
986
+
987
+ - Deleted `@classytic/primitives/errors` (`ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`).
988
+ - 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.
989
+
990
+ #### Migration
991
+
992
+ ```diff
993
+ - import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/primitives/errors';
994
+ - import { ERROR_CODES } from '@classytic/primitives/errors';
995
+ + import type { ErrorContract, ErrorDetail, ErrorCode } from '@classytic/repo-core/errors';
996
+ + import { ERROR_CODES } from '@classytic/repo-core/errors';
997
+ ```
998
+
999
+ Field shapes and code values are unchanged — pure relocation.
1000
+
1001
+ ### Removed — `/tenant` subpath (BREAKING)
1002
+
1003
+ - Deleted `@classytic/primitives/tenant` (`TenantConfig`, `TenantStrategy`, `TenantFieldType`, `DEFAULT_TENANT_CONFIG`, `resolveTenantConfig`, `ResolvedTenantConfig`).
1004
+ - 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.
1005
+ - Mongokit and sqlitekit already peer-dep `@classytic/repo-core`, so consuming `TenantConfig` from there costs zero extra peer deps for those kits.
1006
+
1007
+ #### Migration
1008
+
1009
+ ```diff
1010
+ - import type { TenantConfig } from '@classytic/primitives/tenant';
1011
+ - import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/primitives/tenant';
1012
+ + import type { TenantConfig } from '@classytic/repo-core/tenant';
1013
+ + import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/repo-core/tenant';
1014
+ ```
1015
+
1016
+ Field shapes, runtime semantics, and defaults are unchanged. The custom-strategy escape hatch (`strategy: 'custom'` + `resolve: (ctx) => filterShape`) works identically.
1017
+
1018
+ ### Removed — `/pagination` subpath (BREAKING)
1019
+
1020
+ - Deleted `@classytic/primitives/pagination` (`PageParams`, `KeysetParams`, `SortSpec`, `SortDirection`, `OffsetPage<T>`, `KeysetPage<T>`, `AggregatePage<T>`, `emptyOffsetPage`, `emptyKeysetPage`).
1021
+ - 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.
1022
+
1023
+ ### Migration
1024
+
1025
+ ```diff
1026
+ - import type { OffsetPage, KeysetPage, AggregatePage, SortSpec } from '@classytic/primitives/pagination';
1027
+ + import type {
1028
+ + OffsetPaginationResult,
1029
+ + KeysetPaginationResult,
1030
+ + AggregatePaginationResult,
1031
+ + SortSpec,
1032
+ + } from '@classytic/repo-core/pagination';
1033
+ ```
1034
+
1035
+ 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.
1036
+
1037
+ 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.
1038
+
1039
+ ### Why now (and why this is the only breaking change in this release)
1040
+
1041
+ 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.
1042
+
1043
+ ## [0.2.0]
1044
+
1045
+ ### Added
1046
+
1047
+ - **`/state-machine` subpath** — `defineStateMachine<TStatus>({ name, transitions, errorFactory? })`
1048
+ declarative state-transition primitive. Replaces hand-rolled
1049
+ `if (status === 'X') throw` blocks across kernels:
1050
+
1051
+ ```typescript
1052
+ import { defineStateMachine } from '@classytic/primitives/state-machine';
1053
+
1054
+ const ORDER_MACHINE = defineStateMachine<OrderStatus>({
1055
+ name: 'Order',
1056
+ transitions: {
1057
+ draft: ['approved', 'cancelled'],
1058
+ approved: ['shipped', 'cancelled'],
1059
+ shipped: [],
1060
+ cancelled: [],
1061
+ },
1062
+ });
1063
+
1064
+ ORDER_MACHINE.assertTransition('order-1', 'draft', 'approved'); // OK
1065
+ ORDER_MACHINE.assertTransition('order-1', 'shipped', 'draft'); // throws
1066
+ ORDER_MACHINE.canTransition('draft', 'shipped'); // false
1067
+ ORDER_MACHINE.isTerminal('shipped'); // true
1068
+ ```
1069
+
1070
+ Generic over the status string-union — `Record<TStatus, readonly TStatus[]>`
1071
+ on `transitions` enforces exhaustiveness at the call site. Kernels with their
1072
+ own typed transition error class (e.g. flow's `InvalidTransitionError`) wire
1073
+ it via `errorFactory` so the thrown type stays domain-consistent. Standalone
1074
+ callers get the bundled `IllegalTransitionError` (carries `entityType` /
1075
+ `entityId` / `from` / `to` + `code: 'illegal_transition'` + `status: 422`).
1076
+
1077
+ First consumer: `@classytic/flow` `PROCUREMENT_MACHINE` (procurement-order
1078
+ approve / cancel transitions).
1079
+
1080
+ ## [0.1.1]
1081
+
1082
+ ### Added
1083
+
1084
+ - **`MinorUnits<C>` branded type on `/money`** — wire-format monetary amount
1085
+ (integer minor units, branded with phantom currency) for HTTP/JSON fields
1086
+ where a full `Money` struct is overkill. Erases at runtime; `MinorUnits<'BDT'>`
1087
+ vs `MinorUnits<'USD'>` collide at compile time. Pair with structured `Money`
1088
+ for in-process arithmetic. Constructor: `MinorUnits(amount, currency?)`.
1089
+
1090
+ ### Changed
1091
+
1092
+ - **`/tenant` `contextKey` cascade default** — `resolveTenantConfig({ tenantField: 'branchId' })`
1093
+ now defaults `contextKey` to `'branchId'` instead of `'organizationId'`.
1094
+ When a host renames `tenantField`, their `OperationContext` carries the id
1095
+ under the same key in the overwhelming majority of cases; mirroring the
1096
+ rename is the least-surprise default. Explicit `contextKey` still overrides
1097
+ (set when the doc field and context key genuinely diverge). Existing call
1098
+ sites that supplied both `tenantField` and `contextKey` are unaffected.
1099
+
1100
+ ## [0.1.0]
1101
+
1102
+ Initial release. Framework-agnostic TypeScript primitives shared across
1103
+ Classytic packages. Zero runtime dependencies. Node 22+. ESM only.
1104
+ 260 tests (unit + integration) across 20 files.
1105
+
1106
+ All subpaths are importable independently; there is **no root barrel
1107
+ re-export** — consumers must use subpath imports (`@classytic/primitives/money`,
1108
+ `/events`, `/tenant`, …) to keep the Node ESM loader + TypeScript compiler
1109
+ from walking the full module graph on any convenience import.
1110
+
1111
+ ### Subpaths
1112
+
1113
+ - **`/money`** — `Money` type + integer-minor-unit arithmetic (`money`,
1114
+ `addMoney`, `subtractMoney`, `multiplyMoney`, `sumMoney`, `negateMoney`,
1115
+ `absMoney`, `compareMoney`, `equalsMoney`, `isZeroMoney`, `isPositiveMoney`,
1116
+ `isNegativeMoney`, `isMoney`, `fromMajor`, `toMajor`); `CurrencyMismatchError`.
1117
+ - **`/currency`** — `CurrencyCode` brand; `CURRENCIES` table; `MINOR_UNIT_FACTOR`,
1118
+ `minorUnitFactor`, `isCurrencyCode`, `toCurrencyCode`.
1119
+ - **`/address`** — `Address`, `ContactAddress`, `GeoPoint`, `GeoJsonPoint`;
1120
+ `toGeoJsonPoint`, `fromGeoJsonPoint`.
1121
+ - **`/period`** — `DateRange`, `Period`; `isDateRange`, `isWithin`,
1122
+ `rangeDurationMs`.
1123
+ - **`/pagination`** — `PageParams`, `OffsetPage<T>`, `KeysetPage<T>`,
1124
+ `AggregatePage`, `KeysetParams`, `SortSpec`, `SortDirection`;
1125
+ `emptyOffsetPage`, `emptyKeysetPage`.
1126
+ - **`/reference`** — `ExternalRef`, `DocumentRef`, `ObjectIdLike`, `IdLike`;
1127
+ `idToString`, `isExternalRef`.
1128
+ - **`/context`** — `OperationContext` (the identity + tracing bag every
1129
+ Classytic package accepts); `ActorRef`.
1130
+ - **`/events`** — `EventMeta` (arc v2.9-aligned — 13 fields including
1131
+ `schemaVersion`, `causationId`, `partitionKey`, `source`, `idempotencyKey`,
1132
+ `aggregate`), `DomainEvent<T>`, `EventTransport`, `EventHandler`,
1133
+ `EventLogger`, `DeadLetteredEvent<T>`, `PublishManyResult`; `createEvent`,
1134
+ `createChildEvent` (auto-chains `causationId`, inherits `correlationId` /
1135
+ `userId` / `organizationId` / `source` / `idempotencyKey` — NOT
1136
+ `aggregate`), `matchEventPattern`.
1137
+ - **`/outbox`** — transactional outbox **contract** mirrored from arc v2.9:
1138
+ `OutboxStore`, `OutboxWriteOptions`, `OutboxClaimOptions`,
1139
+ `OutboxAcknowledgeOptions`, `OutboxFailOptions`, `OutboxErrorInfo`,
1140
+ `OutboxFailureContext`, `OutboxFailureDecision`, `OutboxFailurePolicy`;
1141
+ `OutboxOwnershipError`, `InvalidOutboxEventError`. **Contract only** —
1142
+ runtime (`EventOutbox`, `MemoryOutboxStore`, `MongoOutboxStore`,
1143
+ `exponentialBackoff`) ships in `@classytic/arc/events` and
1144
+ `@classytic/arc/events/mongo`. Domain packages implement `OutboxStore`
1145
+ without peer-depping arc.
1146
+ - **`/tenant`** — `TenantConfig` (with `strategy: 'field' | 'none' | 'custom'`
1147
+ and optional `resolve(ctx)` for custom strategy), `TenantFieldType`,
1148
+ `TenantStrategy`, `ResolvedTenantConfig`; `DEFAULT_TENANT_CONFIG`,
1149
+ `resolveTenantConfig`. Field names match `@classytic/mongokit`'s
1150
+ `MultiTenantOptions` so a resolved config threads into
1151
+ `multiTenantPlugin({ ... })` without translation.
1152
+ - **`/result`** — `Result<T, E>`; `ok`, `err`, `isOk`, `isErr`, `unwrap`,
1153
+ `mapResult`, `mapError`.
1154
+ - **`/brand`** — `Brand<T, B>`, `Prettify`, `DeepPartial`, `DeepReadonly`,
1155
+ `RequireKeys`, `OptionalKeys`, `NonEmptyArray`.
1156
+ - **`/errors`** — `ErrorContract`, `ErrorDetail`, `ErrorCode`, `ERROR_CODES`.
1157
+ - **`/split-allocation`** — `allocate(total, subjects, method)` returns a
1158
+ `SplitResult` whose parts sum exactly to `total` via the largest-remainder
1159
+ method. Methods: `equal`, `by-qty`, `by-weight`, `by-volume`, `by-value`,
1160
+ `by-percent`. Deterministic — identical inputs produce identical outputs;
1161
+ ties broken by input order. Handles negative totals by sign-flipping.
1162
+ Exports: `allocate`, `isBalanced`, `SplitAllocationError`, types
1163
+ `SplitMethod`, `SplitSubject`, `SplitPart`, `SplitResult`,
1164
+ `SplitAllocationErrorCode`.
1165
+ - **`/approval`** — `ApprovalChain` value object with pure FSM helpers.
1166
+ Supports sequential and parallel chains, per-step quorum
1167
+ (`requiredApprovals`), threshold-gated skips. Any step-level rejection
1168
+ rejects the chain; all steps `approved`|`skipped` approves it. Exports:
1169
+ `createChain`, `applyDecision`, `skipStep`, `nextPendingStep`,
1170
+ `pendingSteps`, `isApproved`, `isRejected`, `isPending`, `decisionCount`,
1171
+ `ApprovalError`, types `ApprovalChain`, `ApprovalStep`, `ApprovalDecision`,
1172
+ `Approver`, `ApprovalStatus`, `ChainOrder`, `ApprovalThreshold`,
1173
+ `CreateChainInput`, `CreateStepInput`, `DecisionInput`, `ApprovalErrorCode`.
1174
+ - **`/cadence`** — recurrence spec with pure UTC date arithmetic. Kinds:
1175
+ `daily`, `weekly`, `monthly`, `yearly`, `cron` (opaque — host parses).
1176
+ `dayOfMonth: 31` snaps to the last day of shorter months; Feb 29 snaps
1177
+ to Feb 28 in non-leap years. Exports: `nextOccurrence`,
1178
+ `occurrencesBetween`, `validateCadence`, `CadenceError`, types `Cadence`,
1179
+ `DailyCadence`, `WeeklyCadence`, `MonthlyCadence`, `YearlyCadence`,
1180
+ `CronCadence`, `CadenceKind`, `IsoWeekday`, `CadenceErrorCode`.
1181
+ - **`/hold`** — `HoldReason` + collection helpers. Host embeds
1182
+ `HoldReason[]` on documents. Exports: `addHold`, `resolveHold`,
1183
+ `activeHolds`, `resolvedHolds`, `isOnHold`, `hasActiveHoldOfCode`,
1184
+ `HoldError`, types `HoldReason`, `HoldActor`, `HoldActorKind`,
1185
+ `AddHoldInput`, `ResolveHoldInput`, `HoldErrorCode`.
1186
+ - **`/sla`** — target duration + breach-detection arithmetic. Policy is
1187
+ carried but not executed — host reads `breachPolicy` (`warn` | `escalate`
1188
+ | `block`) and acts. Exports: `breachedAt`, `remainingMs`, `elapsedMs`,
1189
+ `isBreached`, `consumedFraction`, `validateSLA`, `SLAError`, types
1190
+ `SLA`, `BreachPolicy`, `SLAErrorCode`.
1191
+ - **`/person`** — person/contact shapes shared across order, crm, hr.
1192
+ `PersonName`, `ContactInfo`, `EmergencyContact`, `Gender`; formatters
1193
+ `formatFullName`, `formatDisplayName`.
1194
+
1195
+ ### Design principles
1196
+
1197
+ - Pure functions on plain data. No I/O, no Mongo, no events.
1198
+ - Immutable return values. Helpers return new collections; originals are never mutated.
1199
+ - Deterministic. Same inputs → same outputs. Clock sources injected
1200
+ explicitly so tests don't flake.
1201
+ - Errors are typed. Every module ships an `<Name>Error` class with a
1202
+ discriminated `code: <Name>ErrorCode` string union.
1203
+ - Subpath-imports only — no root barrel.
1204
+
1205
+ ### Ownership direction
1206
+
1207
+ Arc is the source of truth for event types. Primitives **mirrors** arc
1208
+ (`@classytic/arc/events/EventTransport.ts`, `@classytic/arc/events/outbox.ts`)
1209
+ verbatim for `EventMeta`, `DomainEvent`, `EventTransport`,
1210
+ `DeadLetteredEvent`, `EventLogger`, `PublishManyResult`, and the full
1211
+ `OutboxStore` contract surface. Helpers (`createEvent`, `createChildEvent`)
1212
+ are allowed to be more defensive but match arc's signatures.
1213
+
1214
+ Packages that peer-dep `@classytic/primitives` can write
1215
+ `class FooOutboxRepository implements OutboxStore` without pulling arc into
1216
+ their dependency graph; hosts import `EventOutbox` + `MongoOutboxStore` from
1217
+ arc and wire them against package-provided stores.