@classytic/primitives 0.14.0 → 0.16.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
@@ -3,83 +3,56 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
- ## [0.14.0] - 2026-07-23
7
-
8
- ### Added — `/money`: scalar minor-unit helpers (ledger dialect)
9
-
10
- - **`majorToMinorUnits(major, minorUnitDecimals = 2)` /
11
- `minorUnitsToMajor(minor, minorUnitDecimals = 2)` /
12
- `percentOfMinor(minor, ratePercent)` / `formatMinorUnits(minor,
13
- minorUnitDecimals = 2)`** — the currency-neutral, exponent-based scalar
14
- dialect used by the double-entry ledger family, extracted from
15
- `@classytic/ledger/money` (`fromDecimal`/`toDecimal`/`percentage`/
16
- `formatPlain`) so the math has ONE owner (PACKAGE_RULES §8.2).
17
- Deliberately different rounding from `fromMajor`: raw IEEE `Math.round`
18
- (half-up toward +∞, no `toPrecision` cleaning) — the ledger's
19
- characterization-tested wire behavior (`majorToMinorUnits(1.005) === 100`
20
- where `fromMajor(1.005,'USD').amount === 101`; `-10.5 → -10`).
21
- `percentOfMinor` is the EXACT multiply-then-round form (handles QST
22
- 9.975 % without bps loss); hosts that snap rates to basis points (be-prod
23
- `#shared/money`) layer that policy on top before calling.
24
-
25
- ### Added — `/event-infra`: shared in-process bus + memory outbox
26
-
27
- - **`@classytic/primitives/event-infra`** — the dependency-free reference
28
- event RUNTIME every kernel previously copy-pasted (26 drifted
29
- `in-process-bus.ts` copies + 15 `outbox-store.ts` copies across
30
- `commerce/packages/`, ≈1,600 lines). New subpath (not `/events` or
31
- `/outbox`, which stay contract-only per the settled ownership rule; arc
32
- keeps the DURABLE runtime — relay, `MongoOutboxStore`, backoff). Exports:
33
- - `InProcessEventBus` (+ `createInProcessBus`, `InProcessEventBusOptions`,
34
- `ErrorLogger`) — in-process fan-out `EventTransport`, structurally
35
- identical to arc's `MemoryEventTransport`. Union of all kernel variants:
36
- glob matching via `matchEventPattern`, Set-dedup across patterns,
37
- per-handler error isolation with injectable logger (`logger: null` =
38
- silent swallow, the cart dialect), `publishMany`, idempotent `close()`.
39
- Options: `name` (`'in-process-<kernel>'`), `logLabel` for the error line.
40
- - `MemoryOutboxStore` — the test/dev outbox (`save`/`getPending`/
41
- `acknowledge`/`purge` + `all()`/`clear()` inspection helpers). Kernels
42
- re-export it; production durability stays host-wired.
43
- - Kernels keep their public class names as thin subclasses
44
- (`class InProcessPosBus extends InProcessEventBus`), so no kernel API
45
- changes — the bus swap is internal.
46
-
47
- ### Added — `/events`: standard context→meta mapping
48
-
49
- - **`scopedEventMeta(ctx)` + `createScopedEvent(source, type, payload, ctx?,
50
- meta?)` + `EventScopeContext`** — THE context→meta mapping (PACKAGE_RULES
51
- §8.3: `organizationId` rides META) extracted from 25 drifted per-kernel
52
- `events/helpers.ts` copies. `userId` ← `String(actorId)` falling back to
53
- `actorRef` (the commerce dialect), `organizationId` ← stringified,
54
- `correlationId` pass-through; absent fields omitted
55
- (`exactOptionalPropertyTypes`-safe). Kernel helpers become one-liners:
56
- `createPurchaseEvent = (t, p, ctx?, m?) =>
57
- createScopedEvent('@classytic/purchase', t, p, ctx, m)`.
58
-
59
- ### Changed — `EventTransport.subscribe` is now optional
60
-
61
- - **`/events` `EventTransport.subscribe?`** — publish-only transports (outbox
62
- bridge adapters, host wrappers around a `publish(type, payload, meta)`
63
- helper, fire-and-forget pipes) now conform without casting. Hosts were
64
- writing `subscribe: undefined as unknown as EventTransport['subscribe']`
65
- literals or `as unknown as EventTransport` widenings to inject them into
66
- kernels that never call `subscribe` on the injected transport. Consumers
67
- that need in-process delivery must guard for an absent `subscribe` — the
68
- same "detect and fall back" contract `publishMany` and `deadLetter`
69
- already carry (flow's `createSettlementListener` is the reference guard;
70
- it has guarded `?.subscribe` since flow 0.5). Kernels subscribing on
71
- their OWN in-process bus are unaffected — concrete buses still implement
72
- `subscribe`, and a concrete method satisfies an optional member.
73
- - Audited call sites before the change (`commerce/packages/*/src`,
74
- `commerce/spine/packages/*/src`): the `.subscribe` uses on injected
75
- transports are pass-through dispatcher methods hosts opt into (crm/party
76
- `dispatch.ts`), an already-guarded listener (flow's
77
- `createSettlementListener`), and invoice's audit bridge — which only
78
- subscribes when `config.audit` is wired. Each of these sources picks up a
79
- compile-time "possibly undefined" prompt on its NEXT rebuild against this
80
- version, forcing the guard (or a documented throw) exactly where a
81
- publish-only transport would have failed at runtime before. No published
82
- dist behavior changes.
6
+ ## [0.16.0] - 2026-07-27
7
+
8
+ ### Added — `assertApproved` + `CHAIN_INCOMPLETE` (`/approval`)
9
+
10
+ - **`ApprovalErrorCode` gains `'CHAIN_INCOMPLETE'`** — the single canonical
11
+ error code for "finalize/commit attempted before the chain reached `approved`".
12
+ Hosts route it through `rethrowApprovalError` to the `approval.chain_incomplete`
13
+ wire code so all commit/post/finalize boundaries raise the same structured error.
14
+ - **`assertApproved(chain, message?)`** — canonical finalize-time gate: asserts
15
+ the chain has reached `approved`, else throws `ApprovalError('CHAIN_INCOMPLETE')`.
16
+ A `null`/absent chain passes (no chain required). Use at every journal-entry
17
+ post, PO approve, transfer dispatch, etc. to replace per-domain hand-rolled
18
+ divergent codes with one definition.
19
+
20
+ Purely additive — no existing exports changed.
21
+
22
+ ## [0.15.0] - 2026-07-25
23
+
24
+ ### Added — `/retention`: `PurgeEvidence` value object
25
+
26
+ - **New `@classytic/primitives/retention` subpath** exporting **`PurgeEvidence`**
27
+ — the persisted evidence record for a GDPR / data-retention purge operation
28
+ (proof of what was purged, over what scope, by whom, why, and whether
29
+ analytical measures were retained). Shape: `{ id, subject: { ref, model? },
30
+ scope, strategy: 'hard'|'soft'|'anonymize', measuresRetained, processed,
31
+ occurredAt, actor: { ref, kind: 'user'|'system'|'service' }, reason,
32
+ legalBasis?, status: 'completed'|'partial'|'failed', operationId?,
33
+ startedAt?, completedAt?, results?: PurgeResourceResult[],
34
+ verification?: PurgeVerificationSummary }`.
35
+ - **`createPurgeEvidence(input)`** — pure builder (auto-fills `id` + `occurredAt`
36
+ when absent, defaults `status` to `'completed'`). **`isPurgeEvidence(value)`**
37
+ type guard + **`assertPurgeEvidence(value)`** throwing assertion.
38
+
39
+ ### Added — `/canonical`: strict deterministic JSON + integrity digest
40
+
41
+ - **New `@classytic/primitives/canonical` subpath** — `canonicalJson(value)`
42
+ produces a deterministic string (keys sorted recursively, `Date` → `{"$date":"<iso>"}`,
43
+ strict rejection of `undefined`/`NaN`/`BigInt`/`Map`/`Set`/cycles with
44
+ `CanonicalizeError`). **`sha256Hex(input)`** and **`canonicalDigest(value)`**
45
+ (= `sha256Hex(canonicalJson(value))`) — integrity checksums, not tamper-proof
46
+ seals. Uses `node:crypto` directly (same precedent as `/otp`).
47
+
48
+ ### Added — `/event-infra`: `propagateHandlerErrors` option
49
+
50
+ - **`InProcessEventBusOptions.propagateHandlerErrors?: boolean`** (default
51
+ `false` — existing error-isolating behavior unchanged). When `true`, `publish`
52
+ runs handlers sequentially and rethrows the first handler error to the
53
+ publisher — for single-consumer projection/relay lanes where a failed handler
54
+ must not be silently acked. `publishMany` maps each event's failure into its
55
+ `PublishManyResult` entry instead of rejecting the batch.
83
56
 
84
57
  ## [0.13.0] - 2026-07-22
85
58
 
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @classytic/primitives
2
2
 
3
+ [![Sponsor](https://img.shields.io/github/sponsors/classytic?style=flat-square&label=Sponsor&logo=GitHub&color=EA4AAA)](https://github.com/sponsors/classytic)
4
+
3
5
  Framework-agnostic TypeScript primitives shared across Classytic packages.
4
6
  Zero runtime dependencies. ESM only. Node 22+.
5
7
 
@@ -83,7 +83,12 @@ interface CreateStepInput {
83
83
  readonly requiredApprovals?: number;
84
84
  readonly threshold?: ApprovalThreshold;
85
85
  }
86
- type ApprovalErrorCode = 'EMPTY_STEPS' | 'DUPLICATE_STEP_ID' | 'EMPTY_APPROVERS' | 'DUPLICATE_APPROVER_ID' | 'INVALID_QUORUM' | 'UNKNOWN_STEP' | 'UNAUTHORIZED_APPROVER' | 'STEP_NOT_ACTIVE' | 'STEP_ALREADY_DECIDED_BY_APPROVER';
86
+ type ApprovalErrorCode = 'EMPTY_STEPS' | 'DUPLICATE_STEP_ID' | 'EMPTY_APPROVERS' | 'DUPLICATE_APPROVER_ID' | 'INVALID_QUORUM' | 'UNKNOWN_STEP' | 'UNAUTHORIZED_APPROVER' | 'STEP_NOT_ACTIVE' | 'STEP_ALREADY_DECIDED_BY_APPROVER'
87
+ /** A finalize/commit was attempted while the chain has not reached
88
+ * `approved` (still `pending`, or `rejected`). The single canonical
89
+ * "not yet approved" gate — hosts route it through arc-approval's
90
+ * `rethrowApprovalError` to the `approval.chain_incomplete` wire code. */
91
+ | 'CHAIN_INCOMPLETE';
87
92
  declare class ApprovalError extends Error {
88
93
  readonly name = "ApprovalError";
89
94
  readonly code: ApprovalErrorCode;
@@ -123,6 +128,16 @@ declare function applyDecision(chain: ApprovalChain, input: DecisionInput): Appr
123
128
  */
124
129
  declare function skipStep(chain: ApprovalChain, stepId: string, reason?: string): ApprovalChain;
125
130
  declare function isApproved(chain: ApprovalChain): boolean;
131
+ /**
132
+ * The canonical finalize-time gate: assert `chain` has reached `approved`,
133
+ * else throw `ApprovalError('CHAIN_INCOMPLETE')`. Use this at every
134
+ * commit/post/finalize boundary that must not proceed on an unapproved chain
135
+ * (journal-entry post, PO approve, transfer dispatch, …) so they all raise the
136
+ * SAME error instead of hand-rolling divergent codes. A `null`/absent chain is
137
+ * treated as "no chain required" and passes — callers that require a chain
138
+ * check presence separately.
139
+ */
140
+ declare function assertApproved(chain: ApprovalChain | null | undefined, message?: string): void;
126
141
  declare function isRejected(chain: ApprovalChain): boolean;
127
142
  declare function isPending(chain: ApprovalChain): boolean;
128
143
  /**
@@ -130,4 +145,4 @@ declare function isPending(chain: ApprovalChain): boolean;
130
145
  */
131
146
  declare function decisionCount(chain: ApprovalChain): number;
132
147
  //#endregion
133
- export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
148
+ export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
package/dist/approval.mjs CHANGED
@@ -115,6 +115,18 @@ function skipStep(chain, stepId, reason) {
115
115
  function isApproved(chain) {
116
116
  return chain.status === "approved";
117
117
  }
118
+ /**
119
+ * The canonical finalize-time gate: assert `chain` has reached `approved`,
120
+ * else throw `ApprovalError('CHAIN_INCOMPLETE')`. Use this at every
121
+ * commit/post/finalize boundary that must not proceed on an unapproved chain
122
+ * (journal-entry post, PO approve, transfer dispatch, …) so they all raise the
123
+ * SAME error instead of hand-rolling divergent codes. A `null`/absent chain is
124
+ * treated as "no chain required" and passes — callers that require a chain
125
+ * check presence separately.
126
+ */
127
+ function assertApproved(chain, message) {
128
+ if (chain != null && !isApproved(chain)) throw new ApprovalError("CHAIN_INCOMPLETE", message ?? `approval chain has not reached 'approved' (status='${chain.status}')`);
129
+ }
118
130
  function isRejected(chain) {
119
131
  return chain.status === "rejected";
120
132
  }
@@ -143,4 +155,4 @@ function computeChainStatus(steps) {
143
155
  return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
144
156
  }
145
157
  //#endregion
146
- export { ApprovalError, applyDecision, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
158
+ export { ApprovalError, applyDecision, assertApproved, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
@@ -0,0 +1,41 @@
1
+ //#region src/serialization/canonical.d.ts
2
+ /**
3
+ * `@classytic/primitives/canonical` — strict, deterministic JSON canonicalization
4
+ * + a SHA-256 hex helper.
5
+ *
6
+ * Cross-domain vocabulary: any subsystem that needs a STABLE checksum of
7
+ * structured data (a cleanup plan/manifest, a financial-close evidence
8
+ * manifest, an audit seal) canonicalizes here so the digest is:
9
+ *
10
+ * - invariant to object key insertion order (keys sorted recursively);
11
+ * - explicit about `Date` (serialized as `{"$date":"<iso>"}`), so timestamps
12
+ * actually participate in the digest instead of collapsing to `{}` the way
13
+ * a naive `JSON.stringify` of a `Date`-bearing object would; and
14
+ * - STRICT — ambiguous / non-portable values (`undefined`, `NaN`/`Infinity`,
15
+ * `BigInt`, `function`, `symbol`, `Map`, `Set`, cyclic refs) are REJECTED
16
+ * with a `CanonicalizeError` rather than silently mis-hashed.
17
+ *
18
+ * These are integrity CHECKSUMS (accidental-corruption + drift detection), NOT
19
+ * tamper-proof seals: plain SHA-256 does not stop an actor with write access
20
+ * from replacing a record and recomputing its digest. Pair with access control
21
+ * / keyed signatures where that matters.
22
+ *
23
+ * `node:crypto` is used directly (precedent: `@classytic/primitives/identity`
24
+ * OTP already imports `node:crypto`); the zero-runtime-dependency policy covers
25
+ * third-party deps, not Node built-ins.
26
+ */
27
+ declare class CanonicalizeError extends Error {
28
+ constructor(message: string);
29
+ }
30
+ /**
31
+ * Strict canonical JSON string of any value. Deterministic (sorted keys),
32
+ * `Date`-explicit; throws {@link CanonicalizeError} on unsupported/ambiguous
33
+ * input. Arrays keep their order (semantically meaningful).
34
+ */
35
+ declare function canonicalJson(value: unknown): string;
36
+ /** `sha256(input)` as a lowercase hex digest. */
37
+ declare function sha256Hex(input: string): string;
38
+ /** Convenience: `sha256Hex(canonicalJson(value))` — the integrity digest of a value. */
39
+ declare function canonicalDigest(value: unknown): string;
40
+ //#endregion
41
+ export { CanonicalizeError, canonicalDigest, canonicalJson, sha256Hex };
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+ //#region src/serialization/canonical.ts
3
+ /**
4
+ * `@classytic/primitives/canonical` — strict, deterministic JSON canonicalization
5
+ * + a SHA-256 hex helper.
6
+ *
7
+ * Cross-domain vocabulary: any subsystem that needs a STABLE checksum of
8
+ * structured data (a cleanup plan/manifest, a financial-close evidence
9
+ * manifest, an audit seal) canonicalizes here so the digest is:
10
+ *
11
+ * - invariant to object key insertion order (keys sorted recursively);
12
+ * - explicit about `Date` (serialized as `{"$date":"<iso>"}`), so timestamps
13
+ * actually participate in the digest instead of collapsing to `{}` the way
14
+ * a naive `JSON.stringify` of a `Date`-bearing object would; and
15
+ * - STRICT — ambiguous / non-portable values (`undefined`, `NaN`/`Infinity`,
16
+ * `BigInt`, `function`, `symbol`, `Map`, `Set`, cyclic refs) are REJECTED
17
+ * with a `CanonicalizeError` rather than silently mis-hashed.
18
+ *
19
+ * These are integrity CHECKSUMS (accidental-corruption + drift detection), NOT
20
+ * tamper-proof seals: plain SHA-256 does not stop an actor with write access
21
+ * from replacing a record and recomputing its digest. Pair with access control
22
+ * / keyed signatures where that matters.
23
+ *
24
+ * `node:crypto` is used directly (precedent: `@classytic/primitives/identity`
25
+ * OTP already imports `node:crypto`); the zero-runtime-dependency policy covers
26
+ * third-party deps, not Node built-ins.
27
+ */
28
+ var CanonicalizeError = class extends Error {
29
+ constructor(message) {
30
+ super(`canonicalize: ${message}`);
31
+ this.name = "CanonicalizeError";
32
+ }
33
+ };
34
+ function canonicalize(value, seen) {
35
+ if (value === null) return "null";
36
+ const t = typeof value;
37
+ if (t === "string") return JSON.stringify(value);
38
+ if (t === "boolean") return value ? "true" : "false";
39
+ if (t === "number") {
40
+ if (!Number.isFinite(value)) throw new CanonicalizeError(`non-finite number ${String(value)}`);
41
+ return JSON.stringify(value);
42
+ }
43
+ if (t === "undefined") throw new CanonicalizeError("undefined is not serializable");
44
+ if (t === "bigint") throw new CanonicalizeError("bigint is not serializable");
45
+ if (t === "function" || t === "symbol") throw new CanonicalizeError(`${t} is not serializable`);
46
+ if (value instanceof Date) {
47
+ const time = value.getTime();
48
+ if (Number.isNaN(time)) throw new CanonicalizeError("invalid Date");
49
+ return `{"$date":${JSON.stringify(value.toISOString())}}`;
50
+ }
51
+ if (value instanceof Map || value instanceof Set) throw new CanonicalizeError(`${value.constructor.name} is not supported — use a plain object/array`);
52
+ const obj = value;
53
+ if (seen.has(obj)) throw new CanonicalizeError("cyclic reference");
54
+ seen.add(obj);
55
+ try {
56
+ if (Array.isArray(value)) return `[${value.map((v) => canonicalize(v, seen)).join(",")}]`;
57
+ const rec = value;
58
+ return `{${Object.keys(rec).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(rec[k], seen)}`).join(",")}}`;
59
+ } finally {
60
+ seen.delete(obj);
61
+ }
62
+ }
63
+ /**
64
+ * Strict canonical JSON string of any value. Deterministic (sorted keys),
65
+ * `Date`-explicit; throws {@link CanonicalizeError} on unsupported/ambiguous
66
+ * input. Arrays keep their order (semantically meaningful).
67
+ */
68
+ function canonicalJson(value) {
69
+ return canonicalize(value, /* @__PURE__ */ new WeakSet());
70
+ }
71
+ /** `sha256(input)` as a lowercase hex digest. */
72
+ function sha256Hex(input) {
73
+ return createHash("sha256").update(input).digest("hex");
74
+ }
75
+ /** Convenience: `sha256Hex(canonicalJson(value))` — the integrity digest of a value. */
76
+ function canonicalDigest(value) {
77
+ return sha256Hex(canonicalJson(value));
78
+ }
79
+ //#endregion
80
+ export { CanonicalizeError, canonicalDigest, canonicalJson, sha256Hex };
@@ -23,6 +23,36 @@ interface InProcessEventBusOptions {
23
23
  * `[<logLabel>] handler error for <event.type>:`. Default: `name`.
24
24
  */
25
25
  logLabel?: string;
26
+ /**
27
+ * Rethrow handler errors from `publish` instead of isolating them
28
+ * (default: `false` — the historical catch-log-continue behavior).
29
+ *
30
+ * **When to use (`true`)** — single-consumer projection/relay lanes: the
31
+ * bus sits between an outbox relay (arc's `EventOutbox` or equivalent) and
32
+ * exactly ONE subscriber (a projector, read-model builder, or forwarder).
33
+ * There, a thrown handler error MUST propagate to the publisher so the
34
+ * delivery FAILS — the relay then retries with backoff / dead-letters the
35
+ * event instead of silently acknowledging work that was never processed.
36
+ * With the default isolation, the relay sees `publish` resolve, acks the
37
+ * outbox row, and the projection update is lost forever.
38
+ *
39
+ * **When NOT to use (leave `false`)** — shared operational buses with
40
+ * multiple independent subscribers (the normal kernel default bus). One
41
+ * misbehaving subscriber must never crash its siblings or the publishing
42
+ * code path; failures are logged via {@link logger} and the event is
43
+ * considered delivered.
44
+ *
45
+ * Strict-mode semantics: handlers run sequentially in subscription order
46
+ * and `publish` rethrows IMMEDIATELY on the first handler failure — later
47
+ * matching subscribers are SKIPPED for that event. That is the honest
48
+ * contract for a single-consumer lane; if you attach multiple subscribers
49
+ * to a strict bus, understand that a failure in an earlier one starves the
50
+ * later ones. Nothing is logged on the strict path (the propagated error
51
+ * is the publisher's to handle); `publishMany` maps each event's failure
52
+ * into its `PublishManyResult` entry (keyed by `meta.id`) instead of
53
+ * rejecting the batch.
54
+ */
55
+ propagateHandlerErrors?: boolean | undefined;
26
56
  }
27
57
  /**
28
58
  * In-process fan-out `EventTransport` — structurally identical to arc's
@@ -35,6 +65,8 @@ interface InProcessEventBusOptions {
35
65
  * event (Set-dedup — the majority variant across kernels).
36
66
  * - Per-handler error isolation: one failing subscriber never crashes
37
67
  * siblings; the failure is logged (or swallowed with `logger: null`).
68
+ * Opt out per-bus with `propagateHandlerErrors: true` (single-consumer
69
+ * projection/relay lanes — see {@link InProcessEventBusOptions.propagateHandlerErrors}).
38
70
  * - `publishMany` batches per-event outcomes keyed by `meta.id`.
39
71
  * - `close()` clears all subscriptions; publish after close is a no-op;
40
72
  * close is idempotent. NOT durable — engines own it under the fleet
@@ -45,6 +77,7 @@ declare class InProcessEventBus implements EventTransport {
45
77
  private handlers;
46
78
  private readonly logger;
47
79
  private readonly logLabel;
80
+ private readonly propagateHandlerErrors;
48
81
  constructor(options?: InProcessEventBusOptions);
49
82
  publish(event: DomainEvent): Promise<void>;
50
83
  publishMany(events: readonly DomainEvent[]): Promise<PublishManyResult>;
@@ -11,6 +11,8 @@ import { matchEventPattern } from "./events.mjs";
11
11
  * event (Set-dedup — the majority variant across kernels).
12
12
  * - Per-handler error isolation: one failing subscriber never crashes
13
13
  * siblings; the failure is logged (or swallowed with `logger: null`).
14
+ * Opt out per-bus with `propagateHandlerErrors: true` (single-consumer
15
+ * projection/relay lanes — see {@link InProcessEventBusOptions.propagateHandlerErrors}).
14
16
  * - `publishMany` batches per-event outcomes keyed by `meta.id`.
15
17
  * - `close()` clears all subscriptions; publish after close is a no-op;
16
18
  * close is idempotent. NOT durable — engines own it under the fleet
@@ -21,18 +23,26 @@ var InProcessEventBus = class {
21
23
  handlers = /* @__PURE__ */ new Map();
22
24
  logger;
23
25
  logLabel;
26
+ propagateHandlerErrors;
24
27
  constructor(options = {}) {
25
28
  this.name = options.name ?? "in-process";
26
29
  this.logger = options.logger === null ? null : options.logger ?? console;
27
30
  this.logLabel = options.logLabel ?? this.name;
31
+ this.propagateHandlerErrors = options.propagateHandlerErrors ?? false;
28
32
  }
29
33
  async publish(event) {
30
34
  const matched = /* @__PURE__ */ new Set();
31
35
  for (const [pattern, set] of this.handlers.entries()) if (matchEventPattern(pattern, event.type)) for (const h of set) matched.add(h);
32
- for (const handler of matched) try {
33
- await handler(event);
34
- } catch (err) {
35
- this.logger?.error(`[${this.logLabel}] handler error for ${event.type}:`, err);
36
+ for (const handler of matched) {
37
+ if (this.propagateHandlerErrors) {
38
+ await handler(event);
39
+ continue;
40
+ }
41
+ try {
42
+ await handler(event);
43
+ } catch (err) {
44
+ this.logger?.error(`[${this.logLabel}] handler error for ${event.type}:`, err);
45
+ }
36
46
  }
37
47
  }
38
48
  async publishMany(events) {
@@ -0,0 +1,209 @@
1
+ //#region src/retention/purge-evidence.d.ts
2
+ /**
3
+ * `PurgeEvidence` — the persisted evidence record for a GDPR / data-retention
4
+ * purge operation.
5
+ *
6
+ * When a host redacts, soft-deletes, or hard-deletes a slice of data to
7
+ * satisfy a right-to-be-forgotten request or a retention window, it must be
8
+ * able to PROVE — to an auditor, a DPA, or a court — what was purged, over
9
+ * what scope, by whom, why, and whether analytical measures were retained.
10
+ * This value object is that proof. Hosts persist one record per purge op
11
+ * alongside the mutation.
12
+ *
13
+ * A **legal hold is the opposite of a purge trigger** — a hold PREVENTS the
14
+ * purge from running. It is never a reason recorded here; if a hold applies,
15
+ * no `PurgeEvidence` is produced for the held subject in the first place.
16
+ *
17
+ * Pure data + pure builder — no persistence, no I/O, no events. The
18
+ * `strategy` enum is a SUBSET of repo-core's `TenantPurgeStrategy['type']`
19
+ * (`hard` | `soft` | `anonymize`) — a purge that ran a `skip` strategy did
20
+ * nothing, so it produces no evidence, and there is no `custom` variant.
21
+ *
22
+ * **Validation convention.** `@classytic/primitives` is deliberately
23
+ * dependency-free (no Zod, no runtime schema library) so it stays a
24
+ * zero-footprint building block for ~73 downstream consumers. Structural
25
+ * validation is therefore a pure type-guard ({@link isPurgeEvidence}) plus a
26
+ * throwing assertion ({@link assertPurgeEvidence}), matching every other
27
+ * primitive (`isExternalRef`, `isBankTransaction`, ...). Hosts that need a
28
+ * Zod schema derive one at their edge from {@link PurgeEvidence}.
29
+ *
30
+ * @example
31
+ * import { createPurgeEvidence } from '@classytic/primitives/retention';
32
+ *
33
+ * const evidence = createPurgeEvidence({
34
+ * subject: { ref: 'customer:c_123', model: 'Customer' },
35
+ * scope: 'org:org_bd_dhaka',
36
+ * strategy: 'anonymize',
37
+ * measuresRetained: true, // sales measures kept; PII dimension redacted
38
+ * processed: 4210,
39
+ * actor: { ref: 'user:admin_7', kind: 'user' },
40
+ * reason: 'GDPR erasure request #4821',
41
+ * legalBasis: 'GDPR Art. 17',
42
+ * });
43
+ * // evidence.id and evidence.occurredAt are auto-filled.
44
+ */
45
+ /**
46
+ * Strategy that produced the evidence. SUBSET of repo-core's
47
+ * `TenantPurgeStrategy['type']` — `skip` is excluded (a skipped purge
48
+ * mutates nothing, so it has no evidence to record).
49
+ */
50
+ type PurgeStrategyKind = 'hard' | 'soft' | 'anonymize';
51
+ /** What the purge targeted — a reference plus an optional logical model. */
52
+ interface PurgeSubject {
53
+ /**
54
+ * Opaque reference to the purged subject. Any stable string form —
55
+ * `'customer:c_123'`, a bare id, an ExternalRef-style `model:id`. The
56
+ * primitive does not parse it; it is round-tripped verbatim for audit.
57
+ */
58
+ readonly ref: string;
59
+ /** Optional logical model / type name — e.g. `'Customer'`, `'Lead'`. */
60
+ readonly model?: string;
61
+ }
62
+ /** Who ran the purge. */
63
+ interface PurgeActor {
64
+ /** Opaque reference to the actor — `'user:admin_7'`, `'service:retention-cron'`. */
65
+ readonly ref: string;
66
+ /** Actor category, for filtering audit reports by human vs automated action. */
67
+ readonly kind: 'user' | 'system' | 'service';
68
+ }
69
+ /**
70
+ * Terminal outcome of the purge operation the evidence records.
71
+ * - `completed` — every targeted resource succeeded.
72
+ * - `partial` — some resources succeeded, others failed (see {@link
73
+ * PurgeEvidence.results}); the operation is resumable.
74
+ * - `failed` — the operation did not achieve its intended effect.
75
+ * A `partial`/`failed` record is STILL evidence — "we attempted X, this is
76
+ * exactly how far it got" — never suppressed.
77
+ */
78
+ type PurgeStatus = 'completed' | 'partial' | 'failed';
79
+ /**
80
+ * Per-resource / per-provider breakdown of a multi-step purge — one entry
81
+ * per logical resource the operation touched. Optional: a single-resource
82
+ * purge needs only the top-level {@link PurgeEvidence.processed}.
83
+ */
84
+ interface PurgeResourceResult {
85
+ /** Logical resource / module / provider name — NOT a collection name. */
86
+ readonly resource: string;
87
+ /** Rows this resource processed (non-negative integer). */
88
+ readonly processed: number;
89
+ /** Whether this resource's step succeeded. */
90
+ readonly ok: boolean;
91
+ /** Failure message when `ok` is false. */
92
+ readonly error?: string;
93
+ }
94
+ /**
95
+ * Compact verification summary — the recipe's own post-checks, not a raw
96
+ * delete count. `A successful Mongo delete count alone is not success.`
97
+ */
98
+ interface PurgeVerificationSummary {
99
+ /** Whether every verification invariant held. */
100
+ readonly ok: boolean;
101
+ /** How many invariants were checked. */
102
+ readonly checks?: number;
103
+ /** Short human-readable note (e.g. `'trial balance clean; stock reseeded'`). */
104
+ readonly note?: string;
105
+ }
106
+ /**
107
+ * Persisted evidence for one purge operation.
108
+ */
109
+ interface PurgeEvidence {
110
+ /** Unique evidence-record identifier (UUID v4 recommended). */
111
+ readonly id: string;
112
+ /**
113
+ * Optional stable operation id correlating this evidence with the run's
114
+ * logs, audit events, and (for a Cleanup Center) its durable run record.
115
+ * Distinct from {@link id} (the evidence-record id): many evidence rows
116
+ * can share one `operationId`.
117
+ */
118
+ readonly operationId?: string;
119
+ /** What was purged. */
120
+ readonly subject: PurgeSubject;
121
+ /**
122
+ * The scope the purge ran over — free-form but conventionally a
123
+ * `kind:value` tag so reports group cleanly. Examples:
124
+ * `'generation:5'` (retention generation), `'occurrence:<dedupeKey>'`
125
+ * (a specific event occurrence), `'org:<id>'` (a tenant/branch window).
126
+ */
127
+ readonly scope: string;
128
+ /** Strategy that ran — `hard` | `soft` | `anonymize`. */
129
+ readonly strategy: PurgeStrategyKind;
130
+ /** Terminal outcome. Defaults to `completed` from the builder. */
131
+ readonly status: PurgeStatus;
132
+ /**
133
+ * True when analytical MEASURES were retained (the anonymize/soft case
134
+ * where PII dimensions are redacted but aggregates survive); false for a
135
+ * hard deletion that removed the rows entirely.
136
+ */
137
+ readonly measuresRetained: boolean;
138
+ /** Rows actually processed by the purge (non-negative integer). */
139
+ readonly processed: number;
140
+ /**
141
+ * When the purge STARTED, if known. Distinct from {@link completedAt} /
142
+ * {@link occurredAt} so a report can show duration + ordering.
143
+ */
144
+ readonly startedAt?: Date;
145
+ /** When the purge finished, if distinguished from {@link occurredAt}. */
146
+ readonly completedAt?: Date;
147
+ /** When the evidence was recorded (canonical audit timestamp). */
148
+ readonly occurredAt: Date;
149
+ /** Who ran it. */
150
+ readonly actor: PurgeActor;
151
+ /** Human-readable justification — surfaces in audit reports. */
152
+ readonly reason: string;
153
+ /** Optional legal basis citation — e.g. `'GDPR Art. 17'`, `'CCPA §1798.105'`. */
154
+ readonly legalBasis?: string;
155
+ /** Optional per-resource breakdown of a multi-step purge. */
156
+ readonly results?: readonly PurgeResourceResult[];
157
+ /** Optional verification summary — the recipe's own post-checks. */
158
+ readonly verification?: PurgeVerificationSummary;
159
+ }
160
+ /**
161
+ * Input to {@link createPurgeEvidence}. `id` and `occurredAt` are optional —
162
+ * the builder fills them when absent. Everything else is caller-supplied.
163
+ */
164
+ interface CreatePurgeEvidenceInput {
165
+ readonly id?: string;
166
+ readonly operationId?: string;
167
+ readonly subject: PurgeSubject;
168
+ readonly scope: string;
169
+ readonly strategy: PurgeStrategyKind;
170
+ /** Defaults to `'completed'` when omitted. */
171
+ readonly status?: PurgeStatus;
172
+ readonly measuresRetained: boolean;
173
+ readonly processed: number;
174
+ readonly startedAt?: Date;
175
+ readonly completedAt?: Date;
176
+ readonly occurredAt?: Date;
177
+ readonly actor: PurgeActor;
178
+ readonly reason: string;
179
+ readonly legalBasis?: string;
180
+ readonly results?: readonly PurgeResourceResult[];
181
+ readonly verification?: PurgeVerificationSummary;
182
+ }
183
+ /**
184
+ * Build a {@link PurgeEvidence} record with auto-filled `id` + `occurredAt`.
185
+ *
186
+ * Pure and side-effect-free at module scope — the id generator and the
187
+ * timestamp are only invoked when the function runs (mirrors
188
+ * `@classytic/primitives/events` `createEvent`), never at import time, so the
189
+ * module stays tree-shakeable and deterministic to load.
190
+ *
191
+ * Caller-supplied `id` / `occurredAt` win over the generated defaults.
192
+ */
193
+ declare function createPurgeEvidence(input: CreatePurgeEvidenceInput): PurgeEvidence;
194
+ /**
195
+ * Structural type guard — validates the full {@link PurgeEvidence} shape,
196
+ * including the nested `subject` / `actor` objects and the two closed enums.
197
+ * Zero-dependency stand-in for a runtime schema.
198
+ */
199
+ declare function isPurgeEvidence(value: unknown): value is PurgeEvidence;
200
+ /**
201
+ * Throwing assertion form of {@link isPurgeEvidence}. Use at trust
202
+ * boundaries (deserialized audit rows, cross-service payloads) where an
203
+ * invalid record must fail loudly rather than propagate.
204
+ *
205
+ * @throws {TypeError} when `value` is not a well-formed {@link PurgeEvidence}.
206
+ */
207
+ declare function assertPurgeEvidence(value: unknown): asserts value is PurgeEvidence;
208
+ //#endregion
209
+ export { CreatePurgeEvidenceInput, PurgeActor, PurgeEvidence, PurgeResourceResult, PurgeStatus, PurgeStrategyKind, PurgeSubject, PurgeVerificationSummary, assertPurgeEvidence, createPurgeEvidence, isPurgeEvidence };
@@ -0,0 +1,126 @@
1
+ //#region src/retention/purge-evidence.ts
2
+ const STRATEGY_KINDS = new Set([
3
+ "hard",
4
+ "soft",
5
+ "anonymize"
6
+ ]);
7
+ const ACTOR_KINDS = new Set([
8
+ "user",
9
+ "system",
10
+ "service"
11
+ ]);
12
+ const STATUS_KINDS = new Set([
13
+ "completed",
14
+ "partial",
15
+ "failed"
16
+ ]);
17
+ /** Non-empty string check — the guard's baseline for every required label. */
18
+ function isNonEmptyString(v) {
19
+ return typeof v === "string" && v.length > 0;
20
+ }
21
+ function isValidDate(v) {
22
+ return v instanceof Date && !Number.isNaN(v.getTime());
23
+ }
24
+ /**
25
+ * Build a {@link PurgeEvidence} record with auto-filled `id` + `occurredAt`.
26
+ *
27
+ * Pure and side-effect-free at module scope — the id generator and the
28
+ * timestamp are only invoked when the function runs (mirrors
29
+ * `@classytic/primitives/events` `createEvent`), never at import time, so the
30
+ * module stays tree-shakeable and deterministic to load.
31
+ *
32
+ * Caller-supplied `id` / `occurredAt` win over the generated defaults.
33
+ */
34
+ function createPurgeEvidence(input) {
35
+ return {
36
+ id: input.id ?? randomEvidenceId(),
37
+ subject: input.subject,
38
+ scope: input.scope,
39
+ strategy: input.strategy,
40
+ status: input.status ?? "completed",
41
+ measuresRetained: input.measuresRetained,
42
+ processed: input.processed,
43
+ occurredAt: input.occurredAt ?? /* @__PURE__ */ new Date(),
44
+ actor: input.actor,
45
+ reason: input.reason,
46
+ ...input.operationId !== void 0 ? { operationId: input.operationId } : {},
47
+ ...input.startedAt !== void 0 ? { startedAt: input.startedAt } : {},
48
+ ...input.completedAt !== void 0 ? { completedAt: input.completedAt } : {},
49
+ ...input.legalBasis !== void 0 ? { legalBasis: input.legalBasis } : {},
50
+ ...input.results !== void 0 ? { results: input.results } : {},
51
+ ...input.verification !== void 0 ? { verification: input.verification } : {}
52
+ };
53
+ }
54
+ /**
55
+ * Structural type guard — validates the full {@link PurgeEvidence} shape,
56
+ * including the nested `subject` / `actor` objects and the two closed enums.
57
+ * Zero-dependency stand-in for a runtime schema.
58
+ */
59
+ function isPurgeEvidence(value) {
60
+ if (typeof value !== "object" || value === null) return false;
61
+ const v = value;
62
+ if (!isNonEmptyString(v.id)) return false;
63
+ if (v.operationId !== void 0 && !isNonEmptyString(v.operationId)) return false;
64
+ if (!isNonEmptyString(v.scope)) return false;
65
+ if (typeof v.strategy !== "string" || !STRATEGY_KINDS.has(v.strategy)) return false;
66
+ if (typeof v.status !== "string" || !STATUS_KINDS.has(v.status)) return false;
67
+ if (typeof v.measuresRetained !== "boolean") return false;
68
+ if (typeof v.processed !== "number" || !Number.isInteger(v.processed) || v.processed < 0) return false;
69
+ if (v.startedAt !== void 0 && !isValidDate(v.startedAt)) return false;
70
+ if (v.completedAt !== void 0 && !isValidDate(v.completedAt)) return false;
71
+ if (!isValidDate(v.occurredAt)) return false;
72
+ if (!isNonEmptyString(v.reason)) return false;
73
+ if (v.legalBasis !== void 0 && !isNonEmptyString(v.legalBasis)) return false;
74
+ const subject = v.subject;
75
+ if (typeof subject !== "object" || subject === null) return false;
76
+ if (!isNonEmptyString(subject.ref)) return false;
77
+ if (subject.model !== void 0 && !isNonEmptyString(subject.model)) return false;
78
+ const actor = v.actor;
79
+ if (typeof actor !== "object" || actor === null) return false;
80
+ if (!isNonEmptyString(actor.ref)) return false;
81
+ if (typeof actor.kind !== "string" || !ACTOR_KINDS.has(actor.kind)) return false;
82
+ if (v.results !== void 0) {
83
+ if (!Array.isArray(v.results)) return false;
84
+ for (const r of v.results) {
85
+ if (typeof r !== "object" || r === null) return false;
86
+ const rr = r;
87
+ if (!isNonEmptyString(rr.resource)) return false;
88
+ if (typeof rr.processed !== "number" || !Number.isInteger(rr.processed) || rr.processed < 0) return false;
89
+ if (typeof rr.ok !== "boolean") return false;
90
+ if (rr.error !== void 0 && typeof rr.error !== "string") return false;
91
+ }
92
+ }
93
+ if (v.verification !== void 0) {
94
+ if (typeof v.verification !== "object" || v.verification === null) return false;
95
+ const ver = v.verification;
96
+ if (typeof ver.ok !== "boolean") return false;
97
+ if (ver.checks !== void 0 && (!Number.isInteger(ver.checks) || ver.checks < 0)) return false;
98
+ if (ver.note !== void 0 && typeof ver.note !== "string") return false;
99
+ }
100
+ return true;
101
+ }
102
+ /**
103
+ * Throwing assertion form of {@link isPurgeEvidence}. Use at trust
104
+ * boundaries (deserialized audit rows, cross-service payloads) where an
105
+ * invalid record must fail loudly rather than propagate.
106
+ *
107
+ * @throws {TypeError} when `value` is not a well-formed {@link PurgeEvidence}.
108
+ */
109
+ function assertPurgeEvidence(value) {
110
+ if (!isPurgeEvidence(value)) throw new TypeError("Invalid PurgeEvidence: value does not satisfy the PurgeEvidence shape");
111
+ }
112
+ /**
113
+ * UUID-v4-style identifier. Uses `crypto.randomUUID` when available; falls
114
+ * back to an RFC-4122 v4 string only on very old runtimes without Web Crypto.
115
+ * Mirrors the generator in `@classytic/primitives/events`.
116
+ */
117
+ function randomEvidenceId() {
118
+ const g = globalThis;
119
+ if (g.crypto?.randomUUID) return g.crypto.randomUUID();
120
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
121
+ const r = Math.random() * 16 | 0;
122
+ return (c === "x" ? r : r & 3 | 8).toString(16);
123
+ });
124
+ }
125
+ //#endregion
126
+ export { assertPurgeEvidence, createPurgeEvidence, isPurgeEvidence };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",
@@ -157,6 +157,14 @@
157
157
  "types": "./dist/sla-policy.d.mts",
158
158
  "default": "./dist/sla-policy.mjs"
159
159
  },
160
+ "./retention": {
161
+ "types": "./dist/retention.d.mts",
162
+ "default": "./dist/retention.mjs"
163
+ },
164
+ "./canonical": {
165
+ "types": "./dist/canonical.d.mts",
166
+ "default": "./dist/canonical.mjs"
167
+ },
160
168
  "./package.json": "./package.json"
161
169
  },
162
170
  "files": [