@classytic/primitives 0.22.0 → 0.24.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/dist/approval.mjs CHANGED
@@ -39,6 +39,49 @@ function createChain(input) {
39
39
  };
40
40
  }
41
41
  /**
42
+ * The chain for a subject that needs NO approval — zero steps, already
43
+ * resolved. The explicit, greppable answer to "the policy engine ran and
44
+ * decided this document does not require approval."
45
+ *
46
+ * ## Why this is not a fabricated approval
47
+ *
48
+ * {@link computeChainStatus} already folds an empty step list to `'approved'`
49
+ * — "nothing is outstanding" is what approved MEANS to this primitive, and
50
+ * that fold predates this function. What {@link createChain} refuses
51
+ * (`EMPTY_STEPS`) is building a ROUTING chain that routes nowhere, which is a
52
+ * different mistake and stays refused. So this constructor states the same
53
+ * conclusion the fold reaches, under a name a reviewer can search for, rather
54
+ * than letting a caller reach it by handing `createChain` an empty array.
55
+ *
56
+ * ## Why a chain at all, instead of leaving `approvals` absent
57
+ *
58
+ * Because absence is the one input that occurs by accident. A schema that
59
+ * never declared the field strips it, a failed write leaves it unset — and
60
+ * {@link assertApproved} must keep treating that as CHAIN_MISSING. A present,
61
+ * zero-step chain is distinguishable from both a lost chain (absent) and a
62
+ * granted one (has steps and decisions), so every existing gate keeps working
63
+ * unchanged and no caller needs a second predicate beside `isApproved`.
64
+ *
65
+ * ## The hazard, stated plainly
66
+ *
67
+ * This makes {@link isApproved} true without a human deciding anything. It is
68
+ * only correct where a policy engine ASKED and got "no policy applies" — never
69
+ * as a fallback for a lookup that failed, errored, or was not configured. A
70
+ * resolver that cannot reach its policy store must throw; answering "not
71
+ * required" there would auto-approve every document in the deployment, which
72
+ * is precisely the permissive-default failure this codebase designs against.
73
+ *
74
+ * @param reason Why approval was not required, for the audit trail.
75
+ */
76
+ function notRequiredChain(reason) {
77
+ return {
78
+ order: "sequential",
79
+ steps: [],
80
+ status: computeChainStatus([]),
81
+ notRequiredReason: reason
82
+ };
83
+ }
84
+ /**
42
85
  * Returns the next step awaiting a decision — or `null` if none.
43
86
  *
44
87
  * For `sequential` chains: the first `pending` step in order; later steps
@@ -116,6 +159,22 @@ function isApproved(chain) {
116
159
  return chain.status === "approved";
117
160
  }
118
161
  /**
162
+ * Was this chain built by {@link notRequiredChain} — i.e. approval did not
163
+ * apply, as opposed to having been granted?
164
+ *
165
+ * Both are `isApproved`, and for GATING that is correct: neither blocks the
166
+ * operation. Use this only where the difference is worth showing — an audit
167
+ * trail or an approvals UI, where rendering "Approved" for a document nobody
168
+ * approved misleads the reader.
169
+ *
170
+ * Tests the STRUCTURE (no steps ⇒ nobody was ever asked), not
171
+ * `notRequiredReason`, which a subject schema that never declared the field
172
+ * silently drops on write.
173
+ */
174
+ function isNotRequired(chain) {
175
+ return chain.steps.length === 0;
176
+ }
177
+ /**
119
178
  * The canonical finalize-time gate: assert a chain EXISTS and has reached
120
179
  * `approved`, else throw. Use it at every commit/post/finalize boundary that
121
180
  * must not proceed unapproved (journal-entry post, PO approve, transfer
@@ -189,4 +248,4 @@ function computeChainStatus(steps) {
189
248
  return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
190
249
  }
191
250
  //#endregion
192
- export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isPending, isRejected, nextPendingStep, pendingSteps, skipStep };
251
+ export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
@@ -0,0 +1,176 @@
1
+ import { t as CivilDate } from "./timezone-CxUjxmRU.mjs";
2
+ //#region src/scheduling/calendar-facts.d.ts
3
+ /** Inclusive civil-date bounds a dataset is asserted COMPLETE for. */
4
+ interface DateCoverage {
5
+ readonly from: CivilDate;
6
+ readonly through: CivilDate;
7
+ }
8
+ /**
9
+ * A set of dated facts plus the window it is complete for.
10
+ *
11
+ * ## The key type documents intent and CANNOT enforce it
12
+ *
13
+ * `Record<CivilDate, T>` is a mapped type over a branded string, which TypeScript collapses to a plain
14
+ * string index signature — so the brand does not survive in key position and any string is accepted at
15
+ * the type level. The brand is kept because it states what the keys MEAN; every key is validated at
16
+ * construction instead. Do not assume a value of this type has pre-validated keys unless it came from
17
+ * {@link defineDateEdition} or a schema that performs the same checks.
18
+ */
19
+ interface CoveredDateEdition<TEntry extends object> {
20
+ readonly coverage: DateCoverage;
21
+ readonly dates: Readonly<Record<CivilDate, TEntry>>;
22
+ }
23
+ /**
24
+ * Why a lookup could not answer. Each is a DIFFERENT operator remedy, which is why this is not a
25
+ * boolean:
26
+ *
27
+ * - `missing-edition` — nobody has entered this year. Enter it.
28
+ * - `outside-coverage` — the year exists but does not claim to cover this date. Extend the coverage.
29
+ * - `unattested` — data exists but no authorized party has vouched for it. Approve it.
30
+ *
31
+ * `unattested` is never produced by this module: attestation is a property of how a dataset was STORED
32
+ * and by whom, which a pure function cannot observe. It lives in the union so a config layer can return
33
+ * it through the same shape rather than inventing a fourth state.
34
+ */
35
+ type DateFactUnknownReason = 'missing-edition' | 'outside-coverage' | 'unattested';
36
+ /**
37
+ * The three-state answer.
38
+ *
39
+ * **`not-listed` does not mean "working day".** It means this attested dataset does not list the date.
40
+ * Converting that into a working-day, a pay rate or a leave deduction is the consuming domain's job,
41
+ * because the same fact supports different conclusions in each.
42
+ */
43
+ type DateFactLookup<TEntry> = {
44
+ readonly kind: 'listed';
45
+ readonly entry: TEntry;
46
+ } | {
47
+ readonly kind: 'not-listed';
48
+ } | {
49
+ readonly kind: 'unknown';
50
+ readonly reason: DateFactUnknownReason;
51
+ };
52
+ /** Who published a dataset. Required, and required to be non-empty — see {@link defineDateEdition}. */
53
+ interface DateAuthority {
54
+ /** The publishing body, e.g. a ministry. Never an aggregator API that merely relayed it. */
55
+ readonly issuer: string;
56
+ /** The citable instrument — a gazette or circular number. */
57
+ readonly reference: string;
58
+ readonly publishedOn?: CivilDate | undefined;
59
+ }
60
+ /** An edition that names its own authority. The unit of completeness for a jurisdiction fact. */
61
+ interface AttributedDateEdition<TEntry extends object> extends CoveredDateEdition<TEntry> {
62
+ readonly authority: DateAuthority;
63
+ }
64
+ /** One listed date, as an importer supplies it. */
65
+ interface DateEntryInput<TEntry extends object> {
66
+ readonly date: string;
67
+ readonly entry: TEntry;
68
+ }
69
+ interface DefineDateEditionInput<TEntry extends object> {
70
+ /**
71
+ * The edition key, `YYYY`. Checked AGAINST the coverage rather than derived from it — an edition
72
+ * filed under the wrong year is a data-entry error that would otherwise make every lookup in that
73
+ * year report `missing-edition` while the data sat one key away.
74
+ */
75
+ readonly year: string;
76
+ readonly coverage: {
77
+ readonly from: string;
78
+ readonly through: string;
79
+ };
80
+ readonly authority: DateAuthority;
81
+ readonly dates: readonly DateEntryInput<TEntry>[];
82
+ /**
83
+ * Permit an edition covering less than the whole year.
84
+ *
85
+ * OFF by default. An annual public-holiday gazette covers a full year, so partial coverage is
86
+ * usually a half-finished import — and a half-finished import that presents as authoritative is
87
+ * precisely the failure this module exists to prevent. Opt in only where a consumer genuinely needs
88
+ * a partial window.
89
+ */
90
+ readonly allowPartialYear?: boolean;
91
+ }
92
+ /** Thrown by {@link defineDateEdition}. Carries every problem, not the first. */
93
+ declare class InvalidDateEditionError extends Error {
94
+ readonly problems: readonly string[];
95
+ constructor(year: string, problems: readonly string[]);
96
+ }
97
+ /**
98
+ * Build a validated edition, or throw naming every problem.
99
+ *
100
+ * REPORTS ALL problems rather than the first: an operator fixing a year's holiday list wants the whole
101
+ * list of bad rows, and returning one at a time turns one correction into twenty round trips.
102
+ */
103
+ declare function defineDateEdition<TEntry extends object>(input: DefineDateEditionInput<TEntry>): AttributedDateEdition<TEntry>;
104
+ /**
105
+ * A jurisdiction's (or an employer's) dated facts, keyed by year.
106
+ *
107
+ * Year-keyed so an amendment replaces one edition rather than the whole history — and so a lookup for
108
+ * an unentered year is distinguishable from a date nobody listed.
109
+ */
110
+ interface DateFactCalendar<TEntry extends object> {
111
+ /** The zone the civil dates are civil IN. A date has no meaning without it. */
112
+ readonly timeZone: string;
113
+ /** Key is `YYYY`. */
114
+ readonly editions: Readonly<Record<string, AttributedDateEdition<TEntry>>>;
115
+ }
116
+ /** Thrown by {@link defineDateFactCalendar}. */
117
+ declare class InvalidDateFactCalendarError extends Error {
118
+ readonly problems: readonly string[];
119
+ constructor(problems: readonly string[]);
120
+ }
121
+ /**
122
+ * Build a validated calendar. **The timezone is checked here, not trusted.**
123
+ *
124
+ * A civil date has no meaning without a zone, and a WRONG zone is worse than a missing one: it shifts
125
+ * every boundary by the offset while every value still looks like a date. Asia/Dhaka is UTC+6, so a
126
+ * calendar read in UTC disagrees with the business day for six hours at both ends — the same failure
127
+ * that once reported August revenue as July for the last six hours of every month.
128
+ *
129
+ * Also re-checks each edition's key against its own coverage. `defineDateEdition` already enforces that,
130
+ * but a calendar can be assembled from editions that were built elsewhere or filed under the wrong key,
131
+ * and a misfiled edition makes every lookup in its year report `missing-edition` while the data sits one
132
+ * key away.
133
+ */
134
+ declare function defineDateFactCalendar<TEntry extends object>(input: {
135
+ readonly timeZone: string;
136
+ readonly editions: Readonly<Record<string, AttributedDateEdition<TEntry>>>;
137
+ }): DateFactCalendar<TEntry>;
138
+ /**
139
+ * Look up one civil date. NEVER falls back to "not listed" for data that is absent rather than empty.
140
+ *
141
+ * Order matters: the edition is resolved first, then coverage, then membership. Checking membership
142
+ * first would answer `not-listed` for a year nobody has entered — which is the whole bug.
143
+ */
144
+ declare function lookupDateFact<TEntry extends object>(calendar: DateFactCalendar<TEntry>, date: CivilDate): DateFactLookup<TEntry>;
145
+ /**
146
+ * Look up the civil date an INSTANT falls on, resolved in the CALENDAR'S zone.
147
+ *
148
+ * Prefer this over converting yourself. `lookupDateFact` takes a `CivilDate`, and producing one from an
149
+ * instant requires a zone — if the caller picks its own (the server's, or UTC) it will disagree with the
150
+ * calendar near midnight and be wrong by a whole day at the boundary. The calendar already knows the
151
+ * only correct zone, so the conversion belongs here rather than at every call site.
152
+ */
153
+ declare function lookupDateFactAt<TEntry extends object>(calendar: DateFactCalendar<TEntry>, instant: Date): DateFactLookup<TEntry>;
154
+ /** A public holiday, as published. `label` is the occasion's name, for display and audit. */
155
+ interface PublicHolidayEntry {
156
+ readonly label: string;
157
+ }
158
+ /** A jurisdiction's public-holiday calendar. The company-scoped, statutory one. */
159
+ type PublicHolidayCalendar = DateFactCalendar<PublicHolidayEntry>;
160
+ /**
161
+ * An employer closure, which unlike a public holiday can also declare a date OPEN.
162
+ *
163
+ * The `open` arm exists because a branch override must be able to CONTRADICT a company-wide closure —
164
+ * the branch works that day. Modelled as a discriminated union rather than a boolean so a merge
165
+ * replaces the whole value per date; recursively merging `{state:'closed',label}` onto
166
+ * `{state:'open',reason}` would leave both fields and satisfy neither arm.
167
+ */
168
+ type EmployerClosureEntry = {
169
+ readonly state: 'closed';
170
+ readonly label: string;
171
+ } | {
172
+ readonly state: 'open';
173
+ readonly reason: string;
174
+ };
175
+ //#endregion
176
+ export { AttributedDateEdition, CoveredDateEdition, DateAuthority, DateCoverage, DateEntryInput, DateFactCalendar, DateFactLookup, DateFactUnknownReason, DefineDateEditionInput, EmployerClosureEntry, InvalidDateEditionError, InvalidDateFactCalendarError, PublicHolidayCalendar, PublicHolidayEntry, defineDateEdition, defineDateFactCalendar, lookupDateFact, lookupDateFactAt };
@@ -0,0 +1,187 @@
1
+ import { civilDateOf, civilDaysBetween, isCivilDate, isValidTimeZone } from "./timezone.mjs";
2
+ //#region src/scheduling/calendar-facts.ts
3
+ /**
4
+ * CALENDAR FACTS — attested sets of civil dates, and a lookup that can say "I do not know".
5
+ *
6
+ * A calendar fact is "this date is listed in a dataset somebody vouches for". It is deliberately NOT
7
+ * "this is a working day": that conclusion also depends on weekly schedules, shifts, substitutions and
8
+ * whichever law is asking. Consumers convert facts into their own verdicts — a tax pack into
9
+ * `holiday | working-day | unknown`, payroll into a premium rate, leave into whether balance is
10
+ * consumed. See {@link DateFactLookup}.
11
+ *
12
+ * ## Why this is a sibling of `sla-policy`, not a field on it
13
+ *
14
+ * `WorkingHours` in `./sla-policy` is an SLA SCHEDULE — weekdays plus start/end minutes, with an
15
+ * optional list of skipped dates. Gazette authority and completeness are not schedule properties, and
16
+ * bolting them on would couple statutory tax evidence to support hours while leaving the meaning of the
17
+ * shared field ambiguous per consumer. So these are separate types over the same `CivilDate`.
18
+ *
19
+ * ## The property everything else rests on: COVERAGE
20
+ *
21
+ * A bare `string[]` of holidays cannot express *unknown*. A date absent from the list is
22
+ * indistinguishable from a date nobody has entered yet, so a 2029 deadline checked against a 2026 list
23
+ * reads as a working day — a confident wrong answer, from data that was simply not there.
24
+ *
25
+ * An {@link CoveredDateEdition} therefore carries the bounds it is asserted COMPLETE for. Inside them,
26
+ * absent means not-listed. Outside them, absent means `unknown`. A dataset cannot prove its own
27
+ * completeness, so someone has to declare it — the same reasoning as a tax pack's gazette-coverage
28
+ * claim, and the reason a missing edition can never silently become an empty-but-complete one.
29
+ *
30
+ * ## Input is a LIST, storage is date-KEYED — and that is not incidental
31
+ *
32
+ * The constructors take an array (what an importer or a spreadsheet produces) and return a record keyed
33
+ * by date. Two reasons:
34
+ *
35
+ * 1. **duplicates are caught at construction.** A list can carry the same date twice with different
36
+ * labels; a record cannot, and building one silently would drop whichever came first.
37
+ * 2. **records overlay safely, arrays do not.** A config layer that merges a branch override onto a
38
+ * company default REPLACES arrays wholesale — so a branch list would erase the company list with
39
+ * no error at all. Keyed entries merge per date.
40
+ */
41
+ /** Thrown by {@link defineDateEdition}. Carries every problem, not the first. */
42
+ var InvalidDateEditionError = class extends Error {
43
+ problems;
44
+ constructor(year, problems) {
45
+ super(`[primitives] Cannot define a date edition for "${year}" — ${problems.length} problem(s):\n` + problems.map((p) => ` - ${p}`).join("\n"));
46
+ this.name = "InvalidDateEditionError";
47
+ this.problems = problems;
48
+ }
49
+ };
50
+ const YEAR_PATTERN = /^\d{4}$/;
51
+ /**
52
+ * Build a validated edition, or throw naming every problem.
53
+ *
54
+ * REPORTS ALL problems rather than the first: an operator fixing a year's holiday list wants the whole
55
+ * list of bad rows, and returning one at a time turns one correction into twenty round trips.
56
+ */
57
+ function defineDateEdition(input) {
58
+ const problems = [];
59
+ const { year, coverage, authority, dates, allowPartialYear = false } = input;
60
+ if (!YEAR_PATTERN.test(year)) problems.push(`year must be YYYY; got "${year}"`);
61
+ if (!authority || !authority.issuer?.trim()) problems.push("authority.issuer is required and must be non-empty — an unattributed dataset cannot support a statutory claim");
62
+ if (!authority || !authority.reference?.trim()) problems.push("authority.reference is required and must be non-empty — cite the instrument, not the tool that relayed it");
63
+ if (authority?.publishedOn !== void 0 && !isCivilDate(authority.publishedOn)) problems.push(`authority.publishedOn must be a civil date (YYYY-MM-DD); got "${String(authority.publishedOn)}"`);
64
+ const from = coverage?.from;
65
+ const through = coverage?.through;
66
+ if (!isCivilDate(from)) problems.push(`coverage.from must be a civil date (YYYY-MM-DD); got "${String(from)}"`);
67
+ if (!isCivilDate(through)) problems.push(`coverage.through must be a civil date (YYYY-MM-DD); got "${String(through)}"`);
68
+ if (isCivilDate(from) && isCivilDate(through)) {
69
+ if (civilDaysBetween(from, through) < 0) problems.push(`coverage.from (${from}) is after coverage.through (${through})`);
70
+ if (YEAR_PATTERN.test(year)) {
71
+ if (from.slice(0, 4) !== year || through.slice(0, 4) !== year) problems.push(`edition key "${year}" disagrees with its coverage (${from} … ${through}) — an edition spans exactly one year`);
72
+ else if (!allowPartialYear && (from !== `${year}-01-01` || through !== `${year}-12-31`)) problems.push(`coverage (${from} … ${through}) is narrower than the full year "${year}". An annual gazette covers the whole year, so this is usually an unfinished import; pass allowPartialYear if it is deliberate`);
73
+ }
74
+ }
75
+ const built = {};
76
+ const seen = /* @__PURE__ */ new Set();
77
+ for (const [index, row] of dates.entries()) {
78
+ if (!isCivilDate(row?.date)) {
79
+ problems.push(`dates[${index}].date must be a civil date (YYYY-MM-DD); got "${String(row?.date)}"`);
80
+ continue;
81
+ }
82
+ if (seen.has(row.date)) {
83
+ /**
84
+ * A duplicate is REFUSED, never last-write-wins. Two rows for one date disagree about something,
85
+ * and quietly keeping one drops a fact an operator entered on purpose.
86
+ */
87
+ problems.push(`dates[${index}] repeats ${row.date} — a date may be listed once per edition`);
88
+ continue;
89
+ }
90
+ if (isCivilDate(from) && isCivilDate(through)) {
91
+ if (civilDaysBetween(from, row.date) < 0 || civilDaysBetween(row.date, through) < 0) {
92
+ problems.push(`dates[${index}] (${row.date}) falls outside the declared coverage ${from} … ${through}`);
93
+ continue;
94
+ }
95
+ }
96
+ if (row.entry === null || typeof row.entry !== "object") {
97
+ problems.push(`dates[${index}].entry must be an object; got ${String(row.entry)}`);
98
+ continue;
99
+ }
100
+ seen.add(row.date);
101
+ built[row.date] = row.entry;
102
+ }
103
+ if (problems.length > 0) throw new InvalidDateEditionError(year, problems);
104
+ return {
105
+ coverage: {
106
+ from,
107
+ through
108
+ },
109
+ authority,
110
+ dates: built
111
+ };
112
+ }
113
+ /** Thrown by {@link defineDateFactCalendar}. */
114
+ var InvalidDateFactCalendarError = class extends Error {
115
+ problems;
116
+ constructor(problems) {
117
+ super(`[primitives] Cannot define a date-fact calendar — ${problems.length} problem(s):\n` + problems.map((p) => ` - ${p}`).join("\n"));
118
+ this.name = "InvalidDateFactCalendarError";
119
+ this.problems = problems;
120
+ }
121
+ };
122
+ /**
123
+ * Build a validated calendar. **The timezone is checked here, not trusted.**
124
+ *
125
+ * A civil date has no meaning without a zone, and a WRONG zone is worse than a missing one: it shifts
126
+ * every boundary by the offset while every value still looks like a date. Asia/Dhaka is UTC+6, so a
127
+ * calendar read in UTC disagrees with the business day for six hours at both ends — the same failure
128
+ * that once reported August revenue as July for the last six hours of every month.
129
+ *
130
+ * Also re-checks each edition's key against its own coverage. `defineDateEdition` already enforces that,
131
+ * but a calendar can be assembled from editions that were built elsewhere or filed under the wrong key,
132
+ * and a misfiled edition makes every lookup in its year report `missing-edition` while the data sits one
133
+ * key away.
134
+ */
135
+ function defineDateFactCalendar(input) {
136
+ const problems = [];
137
+ if (typeof input.timeZone !== "string" || input.timeZone.length === 0) problems.push("timeZone is required — a civil date has no meaning without the zone it is civil in");
138
+ else if (!isValidTimeZone(input.timeZone)) problems.push(`timeZone "${input.timeZone}" is not a recognised IANA zone. A wrong zone shifts every boundary by its offset while every value still looks like a valid date`);
139
+ for (const [key, edition] of Object.entries(input.editions)) {
140
+ if (!YEAR_PATTERN.test(key)) {
141
+ problems.push(`edition key "${key}" must be YYYY`);
142
+ continue;
143
+ }
144
+ if (edition.coverage.from.slice(0, 4) !== key || edition.coverage.through.slice(0, 4) !== key) problems.push(`edition "${key}" is filed under a year its coverage (${edition.coverage.from} … ${edition.coverage.through}) does not match — every lookup in ${key} would report missing-edition`);
145
+ }
146
+ if (problems.length > 0) throw new InvalidDateFactCalendarError(problems);
147
+ return {
148
+ timeZone: input.timeZone,
149
+ editions: input.editions
150
+ };
151
+ }
152
+ /**
153
+ * Look up one civil date. NEVER falls back to "not listed" for data that is absent rather than empty.
154
+ *
155
+ * Order matters: the edition is resolved first, then coverage, then membership. Checking membership
156
+ * first would answer `not-listed` for a year nobody has entered — which is the whole bug.
157
+ */
158
+ function lookupDateFact(calendar, date) {
159
+ const edition = calendar.editions[date.slice(0, 4)];
160
+ if (edition === void 0) return {
161
+ kind: "unknown",
162
+ reason: "missing-edition"
163
+ };
164
+ const { from, through } = edition.coverage;
165
+ if (civilDaysBetween(from, date) < 0 || civilDaysBetween(date, through) < 0) return {
166
+ kind: "unknown",
167
+ reason: "outside-coverage"
168
+ };
169
+ const entry = edition.dates[date];
170
+ return entry === void 0 ? { kind: "not-listed" } : {
171
+ kind: "listed",
172
+ entry
173
+ };
174
+ }
175
+ /**
176
+ * Look up the civil date an INSTANT falls on, resolved in the CALENDAR'S zone.
177
+ *
178
+ * Prefer this over converting yourself. `lookupDateFact` takes a `CivilDate`, and producing one from an
179
+ * instant requires a zone — if the caller picks its own (the server's, or UTC) it will disagree with the
180
+ * calendar near midnight and be wrong by a whole day at the boundary. The calendar already knows the
181
+ * only correct zone, so the conversion belongs here rather than at every call site.
182
+ */
183
+ function lookupDateFactAt(calendar, instant) {
184
+ return lookupDateFact(calendar, civilDateOf(instant, calendar.timeZone));
185
+ }
186
+ //#endregion
187
+ export { InvalidDateEditionError, InvalidDateFactCalendarError, defineDateEdition, defineDateFactCalendar, lookupDateFact, lookupDateFactAt };
@@ -0,0 +1,98 @@
1
+ //#region src/composition/fault-attribution.d.ts
2
+ /**
3
+ * Fault attribution value object + pure helpers.
4
+ *
5
+ * Answers ONE question every domain eventually asks: **when something goes
6
+ * wrong, who bears the cost?** A returned item's shipping, a redelivery after a
7
+ * failed drop, a re-issued card, an SLA credit, a scrapped inbound transfer —
8
+ * all the same shape: a reason code implies a responsible party, and whoever is
9
+ * responsible pays.
10
+ *
11
+ * The primitive is *pure data + pure helpers* — no persistence, no events, and
12
+ * deliberately NO vocabulary of its own. Each domain owns its reason codes and
13
+ * supplies a `FaultPolicy` mapping them to parties; the primitive owns the
14
+ * attribution RULES, which are what actually drift between implementations.
15
+ *
16
+ * Consumers: `@classytic/order` (RMA return-shipping liability),
17
+ * `@classytic/flow` (damaged inbound), `@classytic/carrier` (failed delivery
18
+ * attempts), and any SLA credit built on `primitives/sla-policy`.
19
+ *
20
+ * ## Why this exists as a primitive
21
+ *
22
+ * `@classytic/order` documented "`merchantPaysReturnShipping` defaults from the
23
+ * line-level reasons" and then shipped `input.merchantPaysReturnShipping ??
24
+ * false`. The lookup never ran, so a `damaged_in_transit` return — squarely the
25
+ * merchant's fault — billed the CUSTOMER for return shipping. Nothing threw;
26
+ * the field held a plausible boolean.
27
+ *
28
+ * A boolean is the wrong type for this. `false` cannot distinguish "the
29
+ * customer is responsible" from "nobody has decided yet", so the unset case
30
+ * silently becomes a verdict against whoever `false` happens to mean. Modelling
31
+ * the PARTY, with `unknown` as a real member, makes that impossible to express
32
+ * by accident.
33
+ *
34
+ * @example
35
+ * import { attributeFault, bearsCost } from '@classytic/primitives/fault-attribution';
36
+ *
37
+ * const RETURN_FAULT = {
38
+ * defective: 'merchant',
39
+ * damaged_in_transit: 'merchant',
40
+ * changed_mind: 'customer',
41
+ * } as const;
42
+ *
43
+ * attributeFault(RETURN_FAULT, ['damaged_in_transit']); // 'merchant'
44
+ * attributeFault(RETURN_FAULT, ['changed_mind'], 'merchant'); // 'merchant' — explicit wins
45
+ * attributeFault(RETURN_FAULT, ['act_of_god']); // 'unknown' — NOT 'customer'
46
+ * bearsCost(attributeFault(RETURN_FAULT, ['defective']), 'merchant'); // true
47
+ */
48
+ /**
49
+ * Who is responsible for the cost.
50
+ *
51
+ * `unknown` is a first-class member, not a gap. An unrecognised reason is an
52
+ * unanswered question, and answering it by default is how a system quietly
53
+ * bills the wrong party — the same asymmetry that makes a payment timeout
54
+ * `unknown` rather than `declined`.
55
+ *
56
+ * `shared` exists because real disputes settle that way (split shipping,
57
+ * goodwill on a customer-fault return). It is a DECISION, so it is only ever
58
+ * reachable through an explicit override — never inferred from a reason.
59
+ */
60
+ type FaultParty = 'merchant' | 'customer' | 'carrier' | 'shared' | 'unknown';
61
+ /** Reason code → responsible party. The domain owns the keys. */
62
+ type FaultPolicy<TReason extends string = string> = Readonly<Partial<Record<TReason, FaultParty>>>;
63
+ /**
64
+ * Attribute fault across one or more reasons.
65
+ *
66
+ * Rules, in order:
67
+ *
68
+ * 1. **An explicit decision always wins.** A human or an upstream policy that
69
+ * said "the merchant covers this" outranks any reason lookup — a general
70
+ * default must never override a specific instruction.
71
+ * 2. **Merchant fault is sticky.** If ANY reason is the merchant's fault, the
72
+ * whole claim is: a customer returning three items, one of them defective,
73
+ * is not asked to part-pay the shipping. This is the single rule most likely
74
+ * to be re-implemented differently per call site, which is why it lives here.
75
+ * 3. **Carrier before customer.** A carrier-fault reason is not the customer's
76
+ * problem even though the merchant may recover it separately.
77
+ * 4. **Otherwise the reasons must AGREE.** Mixed or unmapped reasons yield
78
+ * `unknown` rather than a guess.
79
+ * 5. **No reasons at all is `unknown`**, never a party.
80
+ */
81
+ declare function attributeFault<TReason extends string>(policy: FaultPolicy<TReason>, reasons: readonly TReason[], explicit?: FaultParty | undefined): FaultParty;
82
+ /**
83
+ * Does `party` bear the cost?
84
+ *
85
+ * `shared` bears it for every named party — a split is not an escape from
86
+ * paying. `unknown` bears nothing: an undecided claim must not auto-charge
87
+ * anyone, which is the whole reason `unknown` is modelled.
88
+ */
89
+ declare function bearsCost(attributed: FaultParty, party: Exclude<FaultParty, 'unknown'>): boolean;
90
+ /**
91
+ * True when attribution is still open and a human (or a later signal) must
92
+ * decide. Use it to route a claim to review instead of letting it settle at a
93
+ * default — an `unknown` that silently behaves like `customer` is the defect
94
+ * this primitive exists to prevent.
95
+ */
96
+ declare function needsFaultReview(attributed: FaultParty): boolean;
97
+ //#endregion
98
+ export { FaultParty, FaultPolicy, attributeFault, bearsCost, needsFaultReview };
@@ -0,0 +1,51 @@
1
+ //#region src/composition/fault-attribution.ts
2
+ /**
3
+ * Attribute fault across one or more reasons.
4
+ *
5
+ * Rules, in order:
6
+ *
7
+ * 1. **An explicit decision always wins.** A human or an upstream policy that
8
+ * said "the merchant covers this" outranks any reason lookup — a general
9
+ * default must never override a specific instruction.
10
+ * 2. **Merchant fault is sticky.** If ANY reason is the merchant's fault, the
11
+ * whole claim is: a customer returning three items, one of them defective,
12
+ * is not asked to part-pay the shipping. This is the single rule most likely
13
+ * to be re-implemented differently per call site, which is why it lives here.
14
+ * 3. **Carrier before customer.** A carrier-fault reason is not the customer's
15
+ * problem even though the merchant may recover it separately.
16
+ * 4. **Otherwise the reasons must AGREE.** Mixed or unmapped reasons yield
17
+ * `unknown` rather than a guess.
18
+ * 5. **No reasons at all is `unknown`**, never a party.
19
+ */
20
+ function attributeFault(policy, reasons, explicit) {
21
+ if (explicit !== void 0) return explicit;
22
+ if (reasons.length === 0) return "unknown";
23
+ const parties = reasons.map((r) => policy[r] ?? "unknown");
24
+ if (parties.includes("merchant")) return "merchant";
25
+ if (parties.includes("carrier")) return "carrier";
26
+ const first = parties[0];
27
+ return parties.every((p) => p === first) ? first : "unknown";
28
+ }
29
+ /**
30
+ * Does `party` bear the cost?
31
+ *
32
+ * `shared` bears it for every named party — a split is not an escape from
33
+ * paying. `unknown` bears nothing: an undecided claim must not auto-charge
34
+ * anyone, which is the whole reason `unknown` is modelled.
35
+ */
36
+ function bearsCost(attributed, party) {
37
+ if (attributed === "unknown") return false;
38
+ if (attributed === "shared") return true;
39
+ return attributed === party;
40
+ }
41
+ /**
42
+ * True when attribution is still open and a human (or a later signal) must
43
+ * decide. Use it to route a claim to review instead of letting it settle at a
44
+ * default — an `unknown` that silently behaves like `customer` is the defect
45
+ * this primitive exists to prevent.
46
+ */
47
+ function needsFaultReview(attributed) {
48
+ return attributed === "unknown";
49
+ }
50
+ //#endregion
51
+ export { attributeFault, bearsCost, needsFaultReview };
@@ -1,5 +1,5 @@
1
1
  import { DeadLetteredEvent, DomainEvent } from "./events.mjs";
2
- import { OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxErrorInfo, OutboxFailOptions, OutboxStatus, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
2
+ import { OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxClaimedEvent, OutboxErrorInfo, OutboxFailOptions, OutboxStatus, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
3
3
  //#region src/events/memory-outbox.d.ts
4
4
  interface MemoryEntry {
5
5
  event: DomainEvent;
@@ -7,6 +7,8 @@ interface MemoryEntry {
7
7
  attempts: number;
8
8
  visibleAt: number;
9
9
  leaseOwner: string | null;
10
+ /** Fencing token — one per claim epoch of this event. */
11
+ fenceToken?: number;
10
12
  leaseExpiresAt: number;
11
13
  deliveredAt: number | null;
12
14
  firstFailedAt: number | null;
@@ -44,6 +46,7 @@ declare class MemoryOutboxStore implements OutboxStore {
44
46
  save(event: DomainEvent, options?: OutboxWriteOptions): Promise<void>;
45
47
  getPending(limit: number): Promise<DomainEvent[]>;
46
48
  claimPending(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
49
+ claimPendingFenced(options?: OutboxClaimOptions): Promise<OutboxClaimedEvent[]>;
47
50
  acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
48
51
  fail(eventId: string, error: OutboxErrorInfo, options?: OutboxFailOptions): Promise<void>;
49
52
  getDeadLettered(limit: number): Promise<DeadLetteredEvent[]>;
@@ -53,6 +53,9 @@ var MemoryOutboxStore = class {
53
53
  return this.entries.filter((e) => e.status === "pending" && e.visibleAt <= now && (e.leaseOwner === null || e.leaseExpiresAt <= now)).slice(0, limit).map((e) => e.event);
54
54
  }
55
55
  async claimPending(options) {
56
+ return (await this.claimPendingFenced(options)).map((c) => c.event);
57
+ }
58
+ async claimPendingFenced(options) {
56
59
  const now = Date.now();
57
60
  const limit = options?.limit ?? 100;
58
61
  const leaseMs = options?.leaseMs ?? DEFAULT_LEASE_MS;
@@ -68,7 +71,11 @@ var MemoryOutboxStore = class {
68
71
  entry.leaseOwner = consumerId;
69
72
  entry.leaseExpiresAt = now + leaseMs;
70
73
  entry.attempts++;
71
- claimed.push(entry.event);
74
+ entry.fenceToken = (entry.fenceToken ?? 0) + 1;
75
+ claimed.push({
76
+ event: entry.event,
77
+ fencingToken: entry.fenceToken
78
+ });
72
79
  }
73
80
  return claimed;
74
81
  }
@@ -77,6 +84,7 @@ var MemoryOutboxStore = class {
77
84
  if (!entry) return;
78
85
  if (entry.status === "delivered") return;
79
86
  if (options?.consumerId && entry.leaseOwner && entry.leaseOwner !== options.consumerId) throw new OutboxOwnershipError(eventId, options.consumerId, entry.leaseOwner);
87
+ if (options?.fencingToken !== void 0 && options.fencingToken !== entry.fenceToken) throw new OutboxOwnershipError(eventId, `token ${options.fencingToken}`, `token ${entry.fenceToken}`);
80
88
  entry.status = "delivered";
81
89
  entry.deliveredAt = Date.now();
82
90
  entry.leaseOwner = null;