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