@classytic/primitives 0.13.0 → 0.15.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,6 +3,41 @@
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.15.0] - 2026-07-25
7
+
8
+ ### Added — `/retention`: `PurgeEvidence` value object
9
+
10
+ - **New `@classytic/primitives/retention` subpath** exporting **`PurgeEvidence`**
11
+ — the persisted evidence record for a GDPR / data-retention purge operation
12
+ (proof of what was purged, over what scope, by whom, why, and whether
13
+ analytical measures were retained). Shape: `{ id, subject: { ref, model? },
14
+ scope, strategy: 'hard'|'soft'|'anonymize', measuresRetained, processed,
15
+ occurredAt, actor: { ref, kind: 'user'|'system'|'service' }, reason,
16
+ legalBasis?, status: 'completed'|'partial'|'failed', operationId?,
17
+ startedAt?, completedAt?, results?: PurgeResourceResult[],
18
+ verification?: PurgeVerificationSummary }`.
19
+ - **`createPurgeEvidence(input)`** — pure builder (auto-fills `id` + `occurredAt`
20
+ when absent, defaults `status` to `'completed'`). **`isPurgeEvidence(value)`**
21
+ type guard + **`assertPurgeEvidence(value)`** throwing assertion.
22
+
23
+ ### Added — `/canonical`: strict deterministic JSON + integrity digest
24
+
25
+ - **New `@classytic/primitives/canonical` subpath** — `canonicalJson(value)`
26
+ produces a deterministic string (keys sorted recursively, `Date` → `{"$date":"<iso>"}`,
27
+ strict rejection of `undefined`/`NaN`/`BigInt`/`Map`/`Set`/cycles with
28
+ `CanonicalizeError`). **`sha256Hex(input)`** and **`canonicalDigest(value)`**
29
+ (= `sha256Hex(canonicalJson(value))`) — integrity checksums, not tamper-proof
30
+ seals. Uses `node:crypto` directly (same precedent as `/otp`).
31
+
32
+ ### Added — `/event-infra`: `propagateHandlerErrors` option
33
+
34
+ - **`InProcessEventBusOptions.propagateHandlerErrors?: boolean`** (default
35
+ `false` — existing error-isolating behavior unchanged). When `true`, `publish`
36
+ runs handlers sequentially and rethrows the first handler error to the
37
+ publisher — for single-consumer projection/relay lanes where a failed handler
38
+ must not be silently acked. `publishMany` maps each event's failure into its
39
+ `PublishManyResult` entry instead of rejecting the batch.
40
+
6
41
  ## [0.13.0] - 2026-07-22
7
42
 
8
43
  ### Added — `/unit-cost-rate`: exact per-unit monetary rates (bigint-safe)
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
 
@@ -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 };
@@ -0,0 +1,112 @@
1
+ import { DomainEvent, EventHandler, EventTransport, PublishManyResult } from "./events.mjs";
2
+ import { OutboxAcknowledgeOptions, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
3
+
4
+ //#region src/events/event-infra.d.ts
5
+ /** Minimal error-only logger — assignable from `console`, pino, fastify.log. */
6
+ interface ErrorLogger {
7
+ error(message: string, ...args: unknown[]): void;
8
+ }
9
+ interface InProcessEventBusOptions {
10
+ /**
11
+ * Transport name (`EventTransport.name`). Kernels use
12
+ * `'in-process-<kernel>'`. Default: `'in-process'`.
13
+ */
14
+ name?: string;
15
+ /**
16
+ * Logger for per-handler error isolation (default: `console`).
17
+ * Pass `null` to swallow handler errors silently (cart's historical
18
+ * behavior — event emission never propagates OR logs).
19
+ */
20
+ logger?: ErrorLogger | null;
21
+ /**
22
+ * Label used in the handler-error log line:
23
+ * `[<logLabel>] handler error for <event.type>:`. Default: `name`.
24
+ */
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;
56
+ }
57
+ /**
58
+ * In-process fan-out `EventTransport` — structurally identical to arc's
59
+ * `MemoryEventTransport`, so the two are interchangeable.
60
+ *
61
+ * Semantics (the union of every kernel copy this replaces):
62
+ * - Glob matching via {@link matchEventPattern} (exact / `*` / `prefix.*`
63
+ * / `prefix:*`).
64
+ * - A handler subscribed under multiple matching patterns fires ONCE per
65
+ * event (Set-dedup — the majority variant across kernels).
66
+ * - Per-handler error isolation: one failing subscriber never crashes
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}).
70
+ * - `publishMany` batches per-event outcomes keyed by `meta.id`.
71
+ * - `close()` clears all subscriptions; publish after close is a no-op;
72
+ * close is idempotent. NOT durable — engines own it under the fleet
73
+ * `ownsBus` convention (PACKAGE_RULES §8.1).
74
+ */
75
+ declare class InProcessEventBus implements EventTransport {
76
+ readonly name: string;
77
+ private handlers;
78
+ private readonly logger;
79
+ private readonly logLabel;
80
+ private readonly propagateHandlerErrors;
81
+ constructor(options?: InProcessEventBusOptions);
82
+ publish(event: DomainEvent): Promise<void>;
83
+ publishMany(events: readonly DomainEvent[]): Promise<PublishManyResult>;
84
+ subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
85
+ close(): Promise<void>;
86
+ }
87
+ /** Factory form of {@link InProcessEventBus} for hosts that prefer functions. */
88
+ declare function createInProcessBus(options?: InProcessEventBusOptions): InProcessEventBus;
89
+ /**
90
+ * In-memory {@link OutboxStore} for tests and local dev. Not for production —
91
+ * hosts wire arc's `MongoOutboxStore`, a mongokit repo via
92
+ * `repositoryAsOutboxStore`, or any durable implementation.
93
+ *
94
+ * Implements the required trio (`save` / `getPending` / `acknowledge`) plus
95
+ * optional `purge`. Lease-based methods (`claimPending` / `fail` /
96
+ * `getDeadLettered`) are intentionally omitted — single-process tests don't
97
+ * need them, and kernels only call `save()` themselves. `all()` / `clear()`
98
+ * are test-inspection helpers (not part of the contract).
99
+ */
100
+ declare class MemoryOutboxStore implements OutboxStore {
101
+ private events;
102
+ save(event: DomainEvent, _options?: OutboxWriteOptions): Promise<void>;
103
+ getPending(limit: number): Promise<DomainEvent[]>;
104
+ acknowledge(eventId: string, _options?: OutboxAcknowledgeOptions): Promise<void>;
105
+ purge(olderThanMs: number): Promise<number>;
106
+ /** Every saved event (acknowledged or not) — test inspection helper. */
107
+ all(): ReadonlyArray<DomainEvent>;
108
+ /** Reset the store — test helper. */
109
+ clear(): void;
110
+ }
111
+ //#endregion
112
+ export { ErrorLogger, InProcessEventBus, InProcessEventBusOptions, MemoryOutboxStore, createInProcessBus };
@@ -0,0 +1,119 @@
1
+ import { matchEventPattern } from "./events.mjs";
2
+ //#region src/events/event-infra.ts
3
+ /**
4
+ * In-process fan-out `EventTransport` — structurally identical to arc's
5
+ * `MemoryEventTransport`, so the two are interchangeable.
6
+ *
7
+ * Semantics (the union of every kernel copy this replaces):
8
+ * - Glob matching via {@link matchEventPattern} (exact / `*` / `prefix.*`
9
+ * / `prefix:*`).
10
+ * - A handler subscribed under multiple matching patterns fires ONCE per
11
+ * event (Set-dedup — the majority variant across kernels).
12
+ * - Per-handler error isolation: one failing subscriber never crashes
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}).
16
+ * - `publishMany` batches per-event outcomes keyed by `meta.id`.
17
+ * - `close()` clears all subscriptions; publish after close is a no-op;
18
+ * close is idempotent. NOT durable — engines own it under the fleet
19
+ * `ownsBus` convention (PACKAGE_RULES §8.1).
20
+ */
21
+ var InProcessEventBus = class {
22
+ name;
23
+ handlers = /* @__PURE__ */ new Map();
24
+ logger;
25
+ logLabel;
26
+ propagateHandlerErrors;
27
+ constructor(options = {}) {
28
+ this.name = options.name ?? "in-process";
29
+ this.logger = options.logger === null ? null : options.logger ?? console;
30
+ this.logLabel = options.logLabel ?? this.name;
31
+ this.propagateHandlerErrors = options.propagateHandlerErrors ?? false;
32
+ }
33
+ async publish(event) {
34
+ const matched = /* @__PURE__ */ new Set();
35
+ for (const [pattern, set] of this.handlers.entries()) if (matchEventPattern(pattern, event.type)) for (const h of set) matched.add(h);
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
+ }
46
+ }
47
+ }
48
+ async publishMany(events) {
49
+ const results = /* @__PURE__ */ new Map();
50
+ for (const event of events) try {
51
+ await this.publish(event);
52
+ results.set(event.meta.id, null);
53
+ } catch (err) {
54
+ results.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
55
+ }
56
+ return results;
57
+ }
58
+ async subscribe(pattern, handler) {
59
+ let set = this.handlers.get(pattern);
60
+ if (!set) {
61
+ set = /* @__PURE__ */ new Set();
62
+ this.handlers.set(pattern, set);
63
+ }
64
+ set.add(handler);
65
+ return () => {
66
+ const s = this.handlers.get(pattern);
67
+ if (!s) return;
68
+ s.delete(handler);
69
+ if (s.size === 0) this.handlers.delete(pattern);
70
+ };
71
+ }
72
+ async close() {
73
+ this.handlers.clear();
74
+ }
75
+ };
76
+ /** Factory form of {@link InProcessEventBus} for hosts that prefer functions. */
77
+ function createInProcessBus(options) {
78
+ return new InProcessEventBus(options);
79
+ }
80
+ /**
81
+ * In-memory {@link OutboxStore} for tests and local dev. Not for production —
82
+ * hosts wire arc's `MongoOutboxStore`, a mongokit repo via
83
+ * `repositoryAsOutboxStore`, or any durable implementation.
84
+ *
85
+ * Implements the required trio (`save` / `getPending` / `acknowledge`) plus
86
+ * optional `purge`. Lease-based methods (`claimPending` / `fail` /
87
+ * `getDeadLettered`) are intentionally omitted — single-process tests don't
88
+ * need them, and kernels only call `save()` themselves. `all()` / `clear()`
89
+ * are test-inspection helpers (not part of the contract).
90
+ */
91
+ var MemoryOutboxStore = class {
92
+ events = [];
93
+ async save(event, _options) {
94
+ this.events.push({ event });
95
+ }
96
+ async getPending(limit) {
97
+ return this.events.filter((e) => !e.acknowledgedAt).slice(0, limit).map((e) => e.event);
98
+ }
99
+ async acknowledge(eventId, _options) {
100
+ const entry = this.events.find((e) => e.event.meta.id === eventId);
101
+ if (entry) entry.acknowledgedAt = /* @__PURE__ */ new Date();
102
+ }
103
+ async purge(olderThanMs) {
104
+ const cutoff = Date.now() - olderThanMs;
105
+ const before = this.events.length;
106
+ this.events = this.events.filter((e) => !e.acknowledgedAt || e.acknowledgedAt.getTime() >= cutoff);
107
+ return before - this.events.length;
108
+ }
109
+ /** Every saved event (acknowledged or not) — test inspection helper. */
110
+ all() {
111
+ return this.events.map((e) => e.event);
112
+ }
113
+ /** Reset the store — test helper. */
114
+ clear() {
115
+ this.events = [];
116
+ }
117
+ };
118
+ //#endregion
119
+ export { InProcessEventBus, MemoryOutboxStore, createInProcessBus };
package/dist/events.d.mts CHANGED
@@ -179,8 +179,18 @@ interface EventTransport {
179
179
  /**
180
180
  * Subscribe to a glob pattern — exact match, `*`, or `resource.*`. Returns
181
181
  * an unsubscribe function.
182
+ *
183
+ * Optional (0.13.0): publish-only transports — outbox bridge adapters,
184
+ * host wrappers around a `publish(type, payload, meta)` helper, fire-and-
185
+ * forget pipes into Kafka/webhooks — are legitimate `EventTransport`s.
186
+ * Consumers that need in-process delivery MUST guard (`transport.subscribe
187
+ * ? … : throw/fallback`) the same way they already do for the optional
188
+ * `publishMany` / `deadLetter` members (see flow's
189
+ * `ConsignmentService.createSettlementListener` for the reference guard).
190
+ * Kernels subscribing on their OWN in-process bus (which always implements
191
+ * `subscribe`) are unaffected.
182
192
  */
183
- subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
193
+ subscribe?(pattern: string, handler: EventHandler): Promise<() => void>;
184
194
  /**
185
195
  * Route a permanently-failed event to the transport's dead-letter sink
186
196
  * (Kafka DLQ topic, SQS DLQ, Redis Stream `PEL` timeout handler, etc.).
@@ -227,6 +237,54 @@ declare function createEvent<T>(type: string, payload: T, meta?: Partial<EventMe
227
237
  * // reserved.meta.userId === user.id (inherited)
228
238
  */
229
239
  declare function createChildEvent<T>(parent: DomainEvent, type: string, payload: T, meta?: Partial<EventMeta>): DomainEvent<T>;
240
+ /**
241
+ * The context slice {@link createScopedEvent} maps into {@link EventMeta}.
242
+ *
243
+ * Structurally satisfied by primitives' `OperationContext` (actorId /
244
+ * organizationId as IdLike) AND by the commerce `actorRef` dialect used by
245
+ * cart/promo/crm — so every kernel context threads straight through.
246
+ */
247
+ interface EventScopeContext {
248
+ /** Authenticated principal id (`OperationContext.actorId` shape). */
249
+ actorId?: string | number | {
250
+ toString(): string;
251
+ } | undefined;
252
+ /** Commerce actor-reference dialect (`user:123`, `guest:abc`). Used when `actorId` is absent. */
253
+ actorRef?: string | undefined;
254
+ /** Tenant / organization / branch scope — rides `meta.organizationId` (META, never payload). */
255
+ organizationId?: string | {
256
+ toString(): string;
257
+ } | undefined;
258
+ /** Correlation id threaded across the causal chain. */
259
+ correlationId?: string | undefined;
260
+ }
261
+ /**
262
+ * Map an operation context onto event META — THE standard context→meta
263
+ * mapping (PACKAGE_RULES §8.3: `organizationId` rides META). Extracted from
264
+ * 25 drifted per-kernel `events/helpers.ts` copies.
265
+ *
266
+ * - `userId` ← `String(ctx.actorId)`, falling back to `ctx.actorRef`
267
+ * - `organizationId` ← `String(ctx.organizationId)`
268
+ * - `correlationId` ← `ctx.correlationId`
269
+ *
270
+ * Absent fields are OMITTED (not set to `undefined`) so the result spreads
271
+ * cleanly under `exactOptionalPropertyTypes`.
272
+ */
273
+ declare function scopedEventMeta(ctx?: EventScopeContext): Partial<EventMeta>;
274
+ /**
275
+ * Build a {@link DomainEvent} scoped to an emitting package and an operation
276
+ * context — the shared core behind every kernel's `createXEvent` helper.
277
+ *
278
+ * ```ts
279
+ * // purchase/src/events/helpers.ts becomes:
280
+ * export const createPurchaseEvent = <T>(type, payload, ctx?, meta?) =>
281
+ * createScopedEvent('@classytic/purchase', type, payload, ctx, meta);
282
+ * ```
283
+ *
284
+ * Precedence: defaults (id/timestamp) < `source` < context mapping < caller
285
+ * `meta` — identical to the hand-rolled helpers this replaces.
286
+ */
287
+ declare function createScopedEvent<T>(source: string, type: string, payload: T, ctx?: EventScopeContext, meta?: Partial<EventMeta>): DomainEvent<T>;
230
288
  /**
231
289
  * Match an event name against a glob pattern. Implements the same rules as
232
290
  * arc's `MemoryEventTransport`:
@@ -237,4 +295,4 @@ declare function createChildEvent<T>(parent: DomainEvent, type: string, payload:
237
295
  */
238
296
  declare function matchEventPattern(pattern: string, type: string): boolean;
239
297
  //#endregion
240
- export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventTransport, PublishManyResult, createChildEvent, createEvent, matchEventPattern };
298
+ export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventScopeContext, EventTransport, PublishManyResult, createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
package/dist/events.mjs CHANGED
@@ -64,6 +64,47 @@ function createChildEvent(parent, type, payload, meta) {
64
64
  };
65
65
  }
66
66
  /**
67
+ * Map an operation context onto event META — THE standard context→meta
68
+ * mapping (PACKAGE_RULES §8.3: `organizationId` rides META). Extracted from
69
+ * 25 drifted per-kernel `events/helpers.ts` copies.
70
+ *
71
+ * - `userId` ← `String(ctx.actorId)`, falling back to `ctx.actorRef`
72
+ * - `organizationId` ← `String(ctx.organizationId)`
73
+ * - `correlationId` ← `ctx.correlationId`
74
+ *
75
+ * Absent fields are OMITTED (not set to `undefined`) so the result spreads
76
+ * cleanly under `exactOptionalPropertyTypes`.
77
+ */
78
+ function scopedEventMeta(ctx) {
79
+ if (!ctx) return {};
80
+ const meta = {};
81
+ const actor = ctx.actorId ?? ctx.actorRef;
82
+ if (actor !== void 0) meta.userId = String(actor);
83
+ if (ctx.organizationId !== void 0) meta.organizationId = String(ctx.organizationId);
84
+ if (ctx.correlationId !== void 0) meta.correlationId = ctx.correlationId;
85
+ return meta;
86
+ }
87
+ /**
88
+ * Build a {@link DomainEvent} scoped to an emitting package and an operation
89
+ * context — the shared core behind every kernel's `createXEvent` helper.
90
+ *
91
+ * ```ts
92
+ * // purchase/src/events/helpers.ts becomes:
93
+ * export const createPurchaseEvent = <T>(type, payload, ctx?, meta?) =>
94
+ * createScopedEvent('@classytic/purchase', type, payload, ctx, meta);
95
+ * ```
96
+ *
97
+ * Precedence: defaults (id/timestamp) < `source` < context mapping < caller
98
+ * `meta` — identical to the hand-rolled helpers this replaces.
99
+ */
100
+ function createScopedEvent(source, type, payload, ctx, meta) {
101
+ return createEvent(type, payload, {
102
+ source,
103
+ ...scopedEventMeta(ctx),
104
+ ...meta
105
+ });
106
+ }
107
+ /**
67
108
  * Match an event name against a glob pattern. Implements the same rules as
68
109
  * arc's `MemoryEventTransport`:
69
110
  * - `*` matches anything
@@ -87,4 +128,4 @@ function randomEventId() {
87
128
  });
88
129
  }
89
130
  //#endregion
90
- export { createChildEvent, createEvent, matchEventPattern };
131
+ export { createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
package/dist/money.d.mts CHANGED
@@ -104,7 +104,38 @@ declare function isPositiveMoney(m: Money): boolean;
104
104
  declare function isNegativeMoney(m: Money): boolean;
105
105
  declare function negateMoney(m: Money): Money;
106
106
  declare function absMoney(m: Money): Money;
107
+ /**
108
+ * Decimal major-unit number → integer minor units by decimal exponent.
109
+ * Rounding: raw IEEE `Math.round` — half-up toward +∞ (so `-10.5 → -10`),
110
+ * documented above. Throws when the result leaves the safe-integer range
111
+ * (also catches `NaN`/`Infinity` inputs).
112
+ *
113
+ * @example
114
+ * majorToMinorUnits(10.5) // 1050
115
+ * majorToMinorUnits(1000, 0) // 1000 (JPY-style)
116
+ * majorToMinorUnits(1.005) // 100 — IEEE, see note above
117
+ */
118
+ declare function majorToMinorUnits(major: number, minorUnitDecimals?: number): number;
119
+ /** Integer minor units → decimal major-unit number by decimal exponent: `1050 → 10.5`. */
120
+ declare function minorUnitsToMajor(minor: number, minorUnitDecimals?: number): number;
121
+ /**
122
+ * Percentage of an integer minor-unit amount → integer minor units.
123
+ * EXACT multiply-then-round: `Math.round((minor × ratePercent) / 100)` —
124
+ * one rounding point, half-up toward +∞. Handles genuinely fractional rates
125
+ * (QST 9.975 %) without precision loss: `percentOfMinor(20000, 9.975) === 1995`.
126
+ *
127
+ * Hosts whose domain guarantees at-most-2-decimal rates MAY snap the rate to
128
+ * integer basis points BEFORE calling (be-prod's `#shared/money` seam does) —
129
+ * that is host policy layered on top, not part of this primitive.
130
+ */
131
+ declare function percentOfMinor(minor: number, ratePercent: number): number;
132
+ /**
133
+ * Integer minor units → plain decimal string (no currency symbol, no locale):
134
+ * `formatMinorUnits(10550) → "105.50"`, `formatMinorUnits(1000, 0) → "1000"`.
135
+ * For localized currency strings use `Intl.NumberFormat` at the display layer.
136
+ */
137
+ declare function formatMinorUnits(minor: number, minorUnitDecimals?: number): string;
107
138
  /** Structural type guard. */
108
139
  declare function isMoney(value: unknown): value is Money;
109
140
  //#endregion
110
- export { CurrencyMismatchError, MinorUnits, Money, absMoney, addMoney, compareMoney, equalsMoney, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, money, multiplyMoney, negateMoney, subtractMoney, sumMoney, toMajor };
141
+ export { CurrencyMismatchError, MinorUnits, Money, absMoney, addMoney, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
package/dist/money.mjs CHANGED
@@ -135,9 +135,52 @@ function negateMoney(m) {
135
135
  function absMoney(m) {
136
136
  return m.amount < 0 ? negateMoney(m) : m;
137
137
  }
138
+ /**
139
+ * Decimal major-unit number → integer minor units by decimal exponent.
140
+ * Rounding: raw IEEE `Math.round` — half-up toward +∞ (so `-10.5 → -10`),
141
+ * documented above. Throws when the result leaves the safe-integer range
142
+ * (also catches `NaN`/`Infinity` inputs).
143
+ *
144
+ * @example
145
+ * majorToMinorUnits(10.5) // 1050
146
+ * majorToMinorUnits(1000, 0) // 1000 (JPY-style)
147
+ * majorToMinorUnits(1.005) // 100 — IEEE, see note above
148
+ */
149
+ function majorToMinorUnits(major, minorUnitDecimals = 2) {
150
+ const factor = 10 ** minorUnitDecimals;
151
+ const minor = Math.round(major * factor);
152
+ if (!Number.isSafeInteger(minor)) throw new RangeError(`Amount ${major} exceeds safe integer limit when converted to minor units. Max safe amount: ${Number.MAX_SAFE_INTEGER / factor}`);
153
+ return minor;
154
+ }
155
+ /** Integer minor units → decimal major-unit number by decimal exponent: `1050 → 10.5`. */
156
+ function minorUnitsToMajor(minor, minorUnitDecimals = 2) {
157
+ return minor / 10 ** minorUnitDecimals;
158
+ }
159
+ /**
160
+ * Percentage of an integer minor-unit amount → integer minor units.
161
+ * EXACT multiply-then-round: `Math.round((minor × ratePercent) / 100)` —
162
+ * one rounding point, half-up toward +∞. Handles genuinely fractional rates
163
+ * (QST 9.975 %) without precision loss: `percentOfMinor(20000, 9.975) === 1995`.
164
+ *
165
+ * Hosts whose domain guarantees at-most-2-decimal rates MAY snap the rate to
166
+ * integer basis points BEFORE calling (be-prod's `#shared/money` seam does) —
167
+ * that is host policy layered on top, not part of this primitive.
168
+ */
169
+ function percentOfMinor(minor, ratePercent) {
170
+ if (!Number.isFinite(minor) || !Number.isFinite(ratePercent)) throw new TypeError(`percentOfMinor received non-finite input: minor=${minor}, ratePercent=${ratePercent}`);
171
+ return Math.round(minor * ratePercent / 100);
172
+ }
173
+ /**
174
+ * Integer minor units → plain decimal string (no currency symbol, no locale):
175
+ * `formatMinorUnits(10550) → "105.50"`, `formatMinorUnits(1000, 0) → "1000"`.
176
+ * For localized currency strings use `Intl.NumberFormat` at the display layer.
177
+ */
178
+ function formatMinorUnits(minor, minorUnitDecimals = 2) {
179
+ return minorUnitsToMajor(minor, minorUnitDecimals).toFixed(minorUnitDecimals);
180
+ }
138
181
  /** Structural type guard. */
139
182
  function isMoney(value) {
140
183
  return typeof value === "object" && value !== null && typeof value.amount === "number" && Number.isInteger(value.amount) && typeof value.currency === "string";
141
184
  }
142
185
  //#endregion
143
- export { CurrencyMismatchError, MinorUnits, absMoney, addMoney, compareMoney, equalsMoney, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, money, multiplyMoney, negateMoney, subtractMoney, sumMoney, toMajor };
186
+ export { CurrencyMismatchError, MinorUnits, absMoney, addMoney, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
@@ -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.13.0",
3
+ "version": "0.15.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",
@@ -61,6 +61,10 @@
61
61
  "types": "./dist/events.d.mts",
62
62
  "default": "./dist/events.mjs"
63
63
  },
64
+ "./event-infra": {
65
+ "types": "./dist/event-infra.d.mts",
66
+ "default": "./dist/event-infra.mjs"
67
+ },
64
68
  "./outbox": {
65
69
  "types": "./dist/outbox.d.mts",
66
70
  "default": "./dist/outbox.mjs"
@@ -153,6 +157,14 @@
153
157
  "types": "./dist/sla-policy.d.mts",
154
158
  "default": "./dist/sla-policy.mjs"
155
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
+ },
156
168
  "./package.json": "./package.json"
157
169
  },
158
170
  "files": [